Generating HTML files in Node.js with React

Published
09.10.2023
Updated
09.10.2023
Category

Sometimes there is a need to generate HTML files in Node.js - for example to create a report or HTML e-mail. This can be done with string concatenation and template literals, but it's not very convenient. Wouldn't it be great if could just use React to generate HTML? Let's see how to do that.

In this tutorial I will use TypeScript, but it's not required. I will also use npm, but other package managers can be used as well.

Setting up the project

We can use an existing project or create a new one. To create a new project, we can use npm init. We will be using TypeScript, so let's run npm install typescript @types/node. The next step is to create a tsconfig.json file. This can be done by running npx tsc --init. One of the options to change is "jsx": "react-jsx". Here's how my tsconfig.json looks like:

jsonExample of tsconfig.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24{ "compilerOptions": { "incremental": true, "target": "es2022", "jsx": "react-jsx", "module": "commonjs", "rootDir": "./src/", "baseUrl": "./src/", "outDir": "./dist", "isolatedModules": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "exactOptionalPropertyTypes": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "skipLibCheck": true, }, }

Rendering React to HTML

The first thing we need to do is to install React and ReactDOM. We can do that by running npm i react react-dom @types/react @types/react-dom. Now we need something to render. Let's create a few components:

TypeScriptsrc/components/Page.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17export interface PageProps extends React.PropsWithChildren { title: string; } export function Page(props: PageProps) { return ( <html> <head> <title>{props.title}</title> <meta charSet="utf-8" /> </head> <body> {props.children} </body> </html> ); }
TypeScriptsrc/components/Report.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29import * as types from "../Report"; import { ReportEntry } from "./ReportEntry"; export interface ReportProps { report: types.Report; } export function Report(props: ReportProps) { return ( <div> <h1>Report: {props.report.title}</h1> <p>Created: {new Date(props.report.createdTimestamp).toISOString()}</p> <table> <thead> <tr> <th>Name</th> <th>Status</th> <th>Message</th> </tr> </thead> <tbody> {props.report.entries.map((entry) => ( <ReportEntry reportEntry={entry} key={entry.id} /> ))} </tbody> </table> </div> ); }
TypeScriptsrc/components/ReportEntry.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15import * as types from "../Report"; export interface ReportEntryProps { reportEntry: types.ReportEntry; } export function ReportEntry(props: ReportEntryProps) { return ( <tr> <td>{props.reportEntry.name}</td> <td>{props.reportEntry.status}</td> <td>{props.reportEntry.message ?? ""}</td> </tr> ); }

We also need to create types for reports:

TypeScriptsrc/Report.ts
1 2 3 4 5 6 7 8 9 10 11 12export interface Report { createdTimestamp: number; title: string; entries: ReportEntry[]; } export interface ReportEntry { id: string; name: string; status: "ok" | "warning" | "error"; message?: string; }

Now it's time to create a sample report and render it to HTML:

TypeScriptsrc/main.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42import { Page } from "./components/Page"; import { ReactRenderer } from "./ReactRenderer"; import * as types from "./Report"; import { Report } from "./components/Report"; const report: types.Report = { createdTimestamp: 1604589425123, title: "Hardware report", entries: [ { id: "webServer", name: "Web Server", status: "ok", }, { id: "oracleServer", name: "Oracle Server", status: "warning", message: "Low disk space", }, { id: "backupServer1", name: "Backup Server 1", status: "ok", message: "Last backup: 2020-11-05 12:00:00", }, { id: "backupServer2", name: "Backup Server 2", status: "error", message: "Server not responding", }, ], }; const renderer = new ReactRenderer(); const html = renderer.renderNodeToHtml( <Page title="Report"> <Report report={report} /> </Page> ); console.log(html);

The last (and the most important) step is to create ReactRenderer. We can use renderToStaticMarkup from react-dom/server to render React to HTML:

TypeScriptsrc/ReactRenderer.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15import type React from "react"; import { renderToStaticMarkup } from "react-dom/server"; export class ReactRenderer { renderNodeToHtml(reactNode: React.ReactNode): string { const html = renderToStaticMarkup( <> {reactNode} </> ); return html; } }

Now we can run src/main.tsx; the result is:

htmlOutput
1<html><head><title>Report</title><meta charSet="utf-8"/></head><body><div><h1>Report: Hardware report</h1><p>Created: 2020-11-05T15:17:05.123Z</p><table><thead><tr><th>Name</th><th>Status</th><th>Message</th></tr></thead><tbody><tr><td>Web Server</td><td>ok</td><td></td></tr><tr><td>Oracle Server</td><td>warning</td><td>Low disk space</td></tr><tr><td>Backup Server 1</td><td>ok</td><td>Last backup: 2020-11-05 12:00:00</td></tr><tr><td>Backup Server 2</td><td>error</td><td>Server not responding</td></tr></tbody></table></div></body></html>

As we can see, the HTML is generated correctly. But what if we want to make it more readable?

Beautifying generated HTML

One of many ways to beautify HTML is to use js-beautify. Let's install it by running npm i js-beautify @types/js-beautify. Now we can update our ReactRenderer, run src/main.tsx and check the result:

TypeScriptsrc/ReactRenderer.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26import jsBeautify from "js-beautify"; import type React from "react"; import { renderToStaticMarkup } from "react-dom/server"; export class ReactRenderer { renderNodeToHtml(reactNode: React.ReactNode): string { const html = renderToStaticMarkup( <> {reactNode} </> ); return this.beautifyHtml(html); } private beautifyHtml(rawHtml: string): string { const beautifiedHtml = jsBeautify.html(rawHtml, { indent_inner_html: true, indent_empty_lines: true, end_with_newline: true, extra_liners: [], }); return beautifiedHtml; } }
htmlOutput
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43<html> <head> <title>Report</title> <meta charSet="utf-8" /> </head> <body> <div> <h1>Report: Hardware report</h1> <p>Created: 2020-11-05T15:17:05.123Z</p> <table> <thead> <tr> <th>Name</th> <th>Status</th> <th>Message</th> </tr> </thead> <tbody> <tr> <td>Web Server</td> <td>ok</td> <td></td> </tr> <tr> <td>Oracle Server</td> <td>warning</td> <td>Low disk space</td> </tr> <tr> <td>Backup Server 1</td> <td>ok</td> <td>Last backup: 2020-11-05 12:00:00</td> </tr> <tr> <td>Backup Server 2</td> <td>error</td> <td>Server not responding</td> </tr> </tbody> </table> </div> </body> </html>

Adding @emotion/styled

What if we wanted to add some styles to our report? We could use style attribute, but it's not very convenient. Let's use @emotion/styled instead. We will also keep our styles in the same HTML file to keep it portable. Let's start by installing required packages: npm i @emotion/styled @emotion/react @emotion/server.

Now it's time to add some styles:

TypeScriptsrc/components/Report.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39import styled from "@emotion/styled"; import * as types from "../Report"; import { ReportEntry } from "./ReportEntry"; export interface ReportProps { report: types.Report; } export function Report(props: ReportProps) { return ( <div> <h1>Report: {props.report.title}</h1> <p>Created: {new Date(props.report.createdTimestamp).toISOString()}</p> <Table> <thead> <tr> <th>Name</th> <th>Status</th> <th>Message</th> </tr> </thead> <tbody> {props.report.entries.map((entry) => ( <ReportEntry reportEntry={entry} key={entry.id} /> ))} </tbody> </Table> </div> ); } const Table = styled.table` border-collapse: collapse; border: 1px solid #ccc; td, th { padding: 5px 10px; } `;

We are going to generate HTML and then we are going to use EmotionServer to extract styles. Finally, we are going to inject styles into <head>:

TypeScriptsrc/ReactRenderer.tsx (final)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49import createCache from "@emotion/cache"; import { CacheProvider } from "@emotion/react"; import createEmotionServer from "@emotion/server/create-instance"; import type { EmotionServer } from "@emotion/server/types/create-instance"; import type { EmotionCache } from "@emotion/utils"; import jsBeautify from "js-beautify"; import type React from "react"; import { renderToStaticMarkup } from "react-dom/server"; export class ReactRenderer { renderNodeToHtml(reactNode: React.ReactNode): string { const { emotionServer, cache } = this.createEmotionServerAndCache(); const unstyledHtml = renderToStaticMarkup( <CacheProvider value={cache}> {reactNode} </CacheProvider> ); const styledHtml = this.injectStylesIntoHead(unstyledHtml, emotionServer); return this.beautifyHtml(styledHtml); } private createEmotionServerAndCache(): { emotionServer: EmotionServer; cache: EmotionCache } { const cache = createCache({ key: "app" }); const emotionServer = createEmotionServer(cache); return { emotionServer, cache }; } private injectStylesIntoHead(unstyledHtml: string, emotionServer: EmotionServer): string { const chunks = emotionServer.extractCriticalToChunks(unstyledHtml); const styles = emotionServer.constructStyleTagsFromChunks(chunks); if (!unstyledHtml.includes("</head>")) { throw new Error("Missing "</head>" string in the html code."); } const styledHtml = unstyledHtml.replace("</head>", `${styles}</head>`); return styledHtml; } private beautifyHtml(rawHtml: string): string { const beautifiedHtml = jsBeautify.html(rawHtml, { indent_inner_html: true, indent_empty_lines: true, end_with_newline: true, extra_liners: [], }); return beautifiedHtml; } }
htmlOutput (final)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55<html> <head> <title>Report</title> <meta charSet="utf-8" /> <style data-emotion="app p9leoo"> .app-p9leoo { border-collapse: collapse; border: 1px solid #ccc; } .app-p9leoo td, .app-p9leoo th { padding: 5px 10px; } </style> </head> <body> <div> <h1>Report: Hardware report</h1> <p>Created: 2020-11-05T15:17:05.123Z</p> <table class="app-p9leoo"> <thead> <tr> <th>Name</th> <th>Status</th> <th>Message</th> </tr> </thead> <tbody> <tr> <td>Web Server</td> <td>ok</td> <td></td> </tr> <tr> <td>Oracle Server</td> <td>warning</td> <td>Low disk space</td> </tr> <tr> <td>Backup Server 1</td> <td>ok</td> <td>Last backup: 2020-11-05 12:00:00</td> </tr> <tr> <td>Backup Server 2</td> <td>error</td> <td>Server not responding</td> </tr> </tbody> </table> </div> </body> </html>

As we can see, the styles are inside the <head> tag and our <table> has a class attribute. Classes are prefixed with app- because we used key: "app" when creating cache in createEmotionServerAndCache(). Of course we can use a different key/prefix.

Conclusion

It's very easy to generate HTML files in Node.js with React. We can use this approach to generate reports, emails, etc. It's also very easy to add styles to our HTML and beautify the result to make it more readable/editable.