How to Take Screenshots in React + Express App

Aug 10, 2023

  • React
  • TypeScript
  • Express
  • Playwright
  • html2canvas

Since there is no built-in feature in JavaScript to take screenshots, you need to use libraries. There are two approaches: relying on the server-side and solving it on the client-side.

Using Puppeteer or Playwright#

Puppeteer and Playwright are libraries for controlling browsers on the server-side. These libraries come with screenshot-taking capabilities that we can leverage. The screenshot functionality of playwright is used for generating OGP images in this blog. You can create an API on the server-side and send requests from React to take screenshots.

bash
1$ npm install playwright
ts
1// server.ts
2
3import express from "express";
4import cors from "cors";
5import playwright from "playwright";
6
7const app = express();
8app.use(express.json());
9app.post('/screenshot', async(res, req, next) => {
10 try {
11 const url = req.query.url;
12 const width = req.query.width;
13 const height = req.query.height;
14
15 if (!url || !width || !height) {
16 throw new Error();
17 }
18
19 // puppeteerの起動
20 const browser = await puppeteer.launch();
21 const page = await browser.newPage();
22 await page.setViewportSize({
23 width: parseInt(width, 10),
24 height: parseInt(height, 10),
25 });
26 await page.goto(url);
27
28 const base64 = (
29 await page.screenshot({
30 fullPage: true,
31 })
32 ).toString("base64");
33 res.send(base64);
34 } catch(e) {
35 next(e);
36 } finally {
37 await browser.close();
38 }
39});
40
41app.listen(8080, () => {
42 console.log("Server is running on port 8080");
43});
ts
1// useScreenshot.ts
2
3import axios from "axios";
4import { useMutation } from "react-query";
5
6export const useScreenshot = () => {
7 return useMutation("getScreenshot", () =>
8 axios.post(
9 `${process.env.API_ENDPOINT}/screenshot?url=${
10 url
11 }&width=${
12 document.body.clientWidth
13 }&height=${
14 document.body.clientHeight
15 }`));
16}

However, there's a drawback with this method, as it can put a heavy load on the server since it launches a browser on the server-side. Additionally, setting up in Docker might be cumbersome, and the image size can be substantial.

Using html2canvas#

html2canvas is a library that captures screenshots using the Canvas API. This makes it quite convenient since we don't need to rely on the server-side.

bash
1$ npm install html2canvas
ts
1// useScreenshot.ts
2
3import html2canvas from "html2canvas";
4
5export const useScreenshot = () => {
6 const capture = (element: HTMLElement) => {
7 try {
8 if (!element) return;
9 const canvas = await html2canvas(element);
10 return canvas.toDataURL("image/png");
11 } catch (e) {
12 console.error(e);
13 }
14 };
15};
tsx
1import { useScreenshot } from "./useScreenshot";
2
3export const App = () => {
4 const { capture } = useScreenshot();
5 const ref = useRef<HTMLDivElement>(null);
6 const [image, setImage] = useState<string | null>(null);
7 const handleClick = () => {
8 const image = capture(ref.current);
9 setImage(image);
10 };
11
12 return (
13 <div ref={ref}>
14 {/* content */}
15 <button onClick={handleClick}>take a screenshot</button>
16 {image && <img src={image} />}
17 </div>
18 )
19}

CORS Images#

For images hosted on your own server, you can handle CORS using the useCORS option, but when using external images, you might encounter CORS errors. In this case, you'll need to rely on the server and use the proxy option to bypass CORS errors.

ts
1// server.ts
2
3...
4
5app.get('/proxy', (res, req, next)=> {
6 try {
7 const url = req.query.url;
8 if (!url) {
9 throw new Error();
10 }
11 const response = await fetch(url);
12 response.arrayBuffer().then((buffer) => {
13 res.send(Buffer.from(buffer));
14 });
15 } catch (e) {
16 next(e);
17 }
18})
19...
tsx
1// useScreenshot.ts
2import html2canvas from "html2canvas";
3import { useState } from "react";
4
5export const useScreenshot = () => {
6 const [isLoading, setIsLoading] = useState(false);
7 const capture = async (element: HTMLElement) => {
8 try {
9 if (!element || !name) return;
10 setIsLoading(true);
11 const canvas = await html2canvas(element, {
12 proxy: `${process.env.API_ENDPOINT}/proxy`,
13 });
14 return canvas.toDataURL("image/png");
15 } catch (e) {
16 console.error(e);
17 } finally {
18 setIsLoading(false);
19 }
20 };
21
22 return { capture, isLoading };
23};

Flexbox Layout Issues#

html2canvas often has problems with text alignment in Flexbox elements. To resolve this, I cloned the repository and made modifications by myself. Keep in mind that this is a workaround.

ts
1// src/render/canvas/canvas-renderer.ts
2
3renderTextWithLetterSpacing(text: TextBounds, letterSpacing: number, baseline: number): void {
4 if (letterSpacing === 0) {
5 this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline / 2 + 2); // <-- I fixed this line
6 } else {
7 const letters = segmentGraphemes(text.text);
8 letters.reduce((left, letter) => {
9 this.ctx.fillText(letter, left, text.bounds.top + baseline);
10
11 return left + this.ctx.measureText(letter).width;
12 }, text.bounds.left);
13 }
14}

Downloading Images#

You can use the <a> tag's download attribute to download images. For a better user experience on iOS, I chose to render the image on a new page and allow users to long-press to save.

tsx
1import html2canvas from "html2canvas";
2import { useState } from "react";
3import { isIos } from "../utils/isIos";
4
5export const useScreenshot = () => {
6 const [isLoading, setIsLoading] = useState(false);
7 const capture = async (element: HTMLElement) => {
8 try {
9 if (!element) return;
10 setIsLoading(true);
11 const canvas = await html2canvas(element, {
12 proxy: `${process.env.API_ENDPOINT}/proxy`,
13 });
14 const data = canvas.toDataURL("image/png");
15
16 if (isIos()) {
17 const image = new Image();
18 image.src = data;
19 const w = window.open("about:blank");
20 w?.document.write(image.outerHTML);
21 w?.document.close();
22 } else {
23 const link = document.createElement("a");
24 link.download = "download.png"
25 link.href = data;
26 link.click();
27 }
28 } catch (e) {
29 console.error(e);
30 } finally {
31 setIsLoading(false);
32 }
33 };
34
35 return { capture, isLoading };
36};