5 min read

Replace Your HTML Entry Point with React

by Daniel Nagy @danielnagydotme
A digital image featuring the React and Vite logos with the text, say goodbye to HTML

Vite has been my daily driver for web development for some time now. One thing that has always bothered me about Vite when building React apps is the HTML entry point.

By default, Vite requires an HTML entry point into your application. This is logical, but it is also undesirable for a couple reasons.

The first problem is that HTML is inherently static. As your app grows in complexity, you may find that you need to add conditional rendering to the head or body of your document.

In addition, rendering your application into an HTML shell makes things like SSR streaming awkward and more difficult.

But why use HTML at all if you are using React? Wouldn't it be great if you could just render the entire document using React?

In this blog post, I will show you how I render the entire document using React in Vite.

INFO

I render my React application on the server. If you only render your React application on the client, then you will need to generate an index.html file when building your app.

In addition, any dynamic bits will need to be based on the build context. Because I render on the server, that is the use case I will be focused on.

Creating a React Entry Point#

To start, I'm going to create a new React component to render the document. I'll put this in a file named Index.tsx.

export type Props = {
children: ReactNode;
};
export const Document: FC<Props> = ({ children }) => (
<html lang="en">
<head>
<meta charSet="UTF-8" />
</head>
<body>{children}</body>
</html>
);

Pretty basic so far. You can add all your static meta tags and whatnot to your document. You don't need to worry about adding a !DOCTYPE declaration because React will do that for you.

Next, I'm going to create two entry points. One for the client and one for the server. I'll name these Main.tsx and MainServer.tsx, respectively.

First, the client entry point:

import { StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { App } from "./App";
import { Document } from "./Index";
hydrateRoot(
document,
<StrictMode>
<Document>
<App />
</Document>
</StrictMode>
);

Notice that when I hydrate the app on the client, I pass the global document object to hydrateRoot. This is because React is now rendering the entire document.

Next, I'll create my server entry point:

/// <reference types="node" />
import { type PipeableStream, renderToPipeableStream } from "react-dom/server";
import { App } from "./App";
import { Document } from "./Index";
export function render(): PipeableStream {
return renderToPipeableStream(
<Document>
<App />
</Document>
);
}

My server entry point exports a function that renders the document and returns a PipeableStream.

INFO

You may, like me, have some hacky code that extracts state from a context provider and inlines it in the document for hydration on the client.

In that case, you won't be able to stream the response to the client. You will need to promisify renderToPipeableStream and await the full HTML content.

I'm hoping to get streaming working after I upgrade to React 19. 🤞

It's time to integrate my new React entrypoint with Vite.

Configuring Vite#

To render my React entry point, I need a server. However, I have a well-defined boundary between my client and my server. They are separate packages with their own dependencies and build steps, and my server is agnostic of my client source code and how it is bundled.

I can't use my server without creating a tangled dependency graph. I could create yet another server, but Vite is already running a dev server, so why not just use that?

I'll create a Vite plugin to render my React application during development. There are some advantages to this. I can maintain the boundary between my client and my server, and Vite can handle static assets without having to add a proxy.

import { type ServerResponse } from "node:http";
import { extname } from "node:path";
import { type Connect, type Plugin } from "vite";
type Input<T> = {
entryFile: string;
render(
entryModule: T,
url: URL,
req: Connect.IncomingMessage,
res: ServerResponse
): void;
};
export const ssr = <T>({ entryFile, render }: Input<T>): Plugin => ({
apply: "serve",
name: "ssr",
configureServer(server) {
return () => {
server.middlewares.use(async (req, res, next) => {
const reqUrl = req.originalUrl ?? req.url;
const host = req.headers["x-forwarded-host"] ?? req.headers.host;
if (!reqUrl) return next();
if (!host) return next();
const url = new URL(reqUrl, `http://${host}`);
if (extname(url.pathname) && url.pathname !== "/index.html")
return next();
const entry = (await server.ssrLoadModule(entryFile)) as T;
render(entry, url, req, res);
});
};
}
});

The plugin takes the path to the entry module and a render function as input. It does some work to resolve the correct URL, delegate requests for static assets to the Vite dev server, and load the entry module using Vite's ssrLoadModule.

It then calls the render function with the entry module, the URL, and the request and response. This is how it is used in my Vite configuration:

import { ssr } from "./vite_plugin_ssr";
type EntryModule = typeof import("./src/MainServer");
export default defineConfig({
plugins: [
ssr<EntryModule>({
entryFile: "./src/MainServer.tsx",
render({ render }, _url, _req, res) {
res.setHeader("Content-type", "text/html; charset=UTF-8");
res.statusCode = 200;
render()
.pipe(res)
.on("end", () => res.end());
}
})
]
});

With that change, the Vite dev server will now render my React app for any request that isn't a file (unless it is the index.html file). But I'm not quite done yet.

I need to inject the Vite dev server scripts during development so that Vite can take over the page once it is loaded in the browser.

To do this, I'm going to add an environment input to my Document component.

export enum Environment {
Development = "development",
Preview = "preview",
Production = "production"
}
export type Props = {
children: ReactNode;
environment: `${Environment}`;
};
export const Document: FC<Props> = ({
children,
environment = Environment.Development
}) => (
<html lang="en">
<head>
<meta charSet="UTF-8" />
</head>
<body>
{children}
{environment === Environment.Development && (
<>
<script
dangerouslySetInnerHTML={{
__html: /* JavaScript */ `
import RefreshRuntime from "/@react-refresh";
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
`
}}
type="module"
/>
<script src="/@vite/client" type="module" />
<script src="/main.tsx" type="module" />
</>
)}
</body>
</html>
);

Now, during development, my app will load the Vite client, my client entry point, and React Fast Refresh (since I'm using the React plugin). With that, I now have a working development environment.

INFO

For API requests to my server, I have a proxy in my Vite configuration. That looks like this.

export default defineConfig({
server: {
proxy: {
"/api": {
changeOrigin: false,
target: "http://localhost:4000"
}
}
}
});

Time to Build#

It is great that I have a working development environment, but at some point I'll need to build my app so I can deploy it to the cloud.

When I build my app, I will need to inject some context from Vite to load the correct assets. Luckily, Vite has a manifest option that makes it easy to get a list of the compiled assets from Vite. Specifically, these are the build options I need:

export default defineConfig({
build: {
manifest: true,
modulePreload: {
polyfill: false
},
rollupOptions: {
input: "src/Main.tsx"
}
}
});

The manifest option will generate a JSON file with data about the build. In addition, I don't use modulepreload so I disable the polyfill, and I need to explicitly set the client entry point for rollup.

The last thing I will do is add a script to my package.json to build both the client and server bundles for my React application:

{
"scripts": {
"prebuild": "tsc -b tsconfig.build.json", // <-- IMPORTANT! Vite does not type check!
"build": "yarn build:client && yarn build:server",
"build:client": "vite build --mode optimized",
"build:server": "vite build --mode optimized --outDir ../build/server --ssr MainServer.tsx",
"postbuild:server": "mv build/server/MainServer.mjs build/server/MainServer.ts"
},
"exports": {
"./*": "./build/*.ts",
"./*.json": "./build/*.json"
}
}

Rather than deal with the bullshit that is Node.js, ES modules, and TypeScript, I renamed the entry point for my server bundle to have a .ts extension. We'll see why this matters later.

That does it for the Vite configuration, but now I need to add a request handler to my server to render my application. This is a bit tricky, though. Remember, I have a boundary between my client and server. So how does my server read the compiled assets from my client?

I could violate the boundary by reaching into the client and importing the assets. But that feels dirty. The reality is that my server has a dependency on my client. So why not treat it like a dependency?

I'll use yarn workspaces to create an explicit dependency between my client and my server. To do this, I'll just add my app workspace as a dependency to my api workspace. In the package.json for my server, it looks like this:

{
"name": "api",
"dependencies": {
"app": "^0.0.0"
}
}

What is great about this is that I can import my client assets on my server as if they were just another dependency.

Before I can implement my request handler, I need to make some changes to my Document component and my server entry point.

I need to update my Document component to add an input for the client JavaScript URL.

export type Props = {
children: ReactNode;
entry?: string;
environment: `${Environment}`;
};
export const Document: FC<Props> = ({
children,
entry,
environment = Environment.Development
}) => (
<html lang="en">
<head>
<meta charSet="UTF-8" />
{entry && <script crossOrigin="" src={entry} type="module" />}
</head>
<body>
{children}
</body>
</html>
);

Then, in my server entry point, I need to add inputs for the environment and manifest and pass the environment and entry to the Document component.

/// <reference types="node" />
import { type PipeableStream, renderToPipeableStream } from "react-dom/server";
import type { Manifest } from "vite";
import { App } from "./App";
import { Document, Environment } from "./Index";
export function render(
environment: Environment,
manifest?: Manifest = {}
): PipeableStream {
const entry = Object.values(manifest).find((chunk) => chunk.isEntry);
return renderToPipeableStream(
<Document entry={entry} environment={environment}>
<App />
</Document>
);
}

Finally, I can add my request handler to the server to render my React app.

import manifest from "app/client/manifest.json" with { type: "json" };
import { render } from "app/server/MainServer";
import type { HonoContext } from "./HonoContext.js";
import { environment } from "./context.js";
export async function ssr(context: HonoContext) {
context.header("Content-Encoding", "gzip");
context.header("Content-type", "text/html; charset=UTF-8");
context.status(200);
const stream = render(environment, manifest).pipe(
new CompressionStream("gzip")
);
return context.body(stream);
}

Because I renamed MainServer.mjs to MainServer.ts, I can just import it into a TypeScript file and get type inference without any headaches 🙌.

Ok, so I'm done now, right?

WARN

Hydration failed because the initial UI does not match what was rendered on the server.

Mother 🤬.

I get a hydration error because when I render my app on the client, I am not passing the same props to my Document component that I am on the server. To fix this, I am going to encode the props as JSON and send them to the client for rehydration.

export const Document: FC<Props> = ({
children,
entry,
environment = Environment.Development
}) => (
<html lang="en">
<body>
{children}
<script
dangerouslySetInnerHTML={{
__html: JSON.stringify({ entry, environment })
}}
id="server_props"
type="application/json"
/>
</body>
</html>
);

Then I can update my client entry point to read these props from the document and pass them to the Document component.

const props = JSON.parse(
document.getElementById("server_props")?.textContent || "{}"
);
hydrateRoot(
document,
<StrictMode>
<Document {...props}>
<App />
</Document>
</StrictMode>
);

And now I'm done 🍻.

Conclusion#

As you might suspect, my actual Document component is much more complex than the example in this blog post. However, once you have a Document component, you can use familiar React APIs to add complexity to your own documents. Because it is just React from top to bottom, things like SSR streaming become much easier.

Now go get rid of your HTML entry points!

References#

Written by Daniel Nagy

Daniel is a software engineer and full-stack web developer. He studied computer science at Ohio University and has been doing web development and hybrid mobile app development for over 8 years.

If you liked this post, then please consider donating or becoming a sponsor. Your support will help me produce more content that gives back to the community.

Buy me a coffeeBecome a sponsor

Comments#

INFO

You will not receive notifications for new comments. If you are waiting for a reply, please check back periodically.

Markdown enabled