Agado Dev

Prerendering a Page in a Single Page Application

Prerendering in a SPA illustration

Building a single-page application (SPA) with a separate frontend and backend architecture offers plenty of advantages: it’s modular, scalable, and simplifies development. However, this architecture also introduces a common challenge—how to deliver a static page optimized for social media sharing and search engine crawling. Client-side rendering (CSR), typical of SPAs, can lead to slower rendering times and make it harder for bots to index pages effectively.

In this article, we’ll explore how to tackle this challenge using a simple approach for prerendering a React-based SPA without relying on a full-fledged metaframework.


The context

For my use case, I’m building a straightforward SaaS application with the following stack:

  • Frontend: React and Vite

  • Backend: Fastify with a PostgreSQL database

  • Hosting: Deployed on Fly.io

This stack is ideal for my needs:

  • Performance: Fast enough for the product’s scope

  • Ease of Development: Quick to build and deploy

  • Cost Efficiency: Hosted on a shared instance with 1 CPU and minimal memory (256MB for the frontend, 512MB for the backend)

However, the CSR approach for the landing page presented two major issues:

  1. Slow Initial Rendering: Rendering content on the client-side delays the time to first meaningful paint

  2. SEO Challenges: Search engine bots often struggle with indexing JavaScript-rendered pages

Using @tanstack/react-router, one potential solution is to implement server-side rendering (SSR) via the https://tanstack.com/start meta framework. However, for a simple use case like this, do we really need the complexity of SSR? The answer is: No.


Why Prerender Instead of SSR?

React has supported server-side rendering since version 0.4.0 (released in 2013). This means we can leverage its rendering capabilities to statically generate HTML at build time without introducing a metaframework. Let’s walk through how to implement this

Step 1: Server Entrypoint

Create a dedicated entry point for rendering the app to an html string on the server using a memory history for routing. Here’s how it looks:

main.server.tsx

import { QueryClient } from "@tanstack/react-query";
import { createMemoryHistory } from "@tanstack/react-router";
import { StrictMode } from "react";
import { renderToString } from "react-dom/server";
import { App } from "./App";
import { createRouter } from "./router";
export async function render(url: string) {
const queryClient = new QueryClient();
const router = createRouter({ queryClient });
const memoryHistory = createMemoryHistory({
initialEntries: ["/"],
});
router.update({
context: { queryClient },
history: memoryHistory,
});
await router.load();
const html = renderToString(
<StrictMode>
<App queryClient={queryClient} router={router} />
</StrictMode>
);
return { html };
}

Step 2: Prepare the HTML Template

Modify your index.html to include placeholders <!--app-head--> and <!--app-html--> for dynamic content. These placeholders will be replaced during prerendering

index.html

<!DOCTYPE html>
<html>
<head>
<!-- Keep your head tags here -->
<!--app-head-->
</head>
<body>
<div id="root"><!--app-html--></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Step 3: Dual Bundling

You’ll need two builds:

  1. SPA Build: The standard client-side application

  2. SSR Build: A server-side bundle for prerendering

Run the following commands for the respective builds:

vite build
vite build --ssr ./src/main.server.tsx --outDir dist/server

Step 4: Prerender Script

Create a script to statically generate the HTML for your home page. This script will:

  1. Load the server-rendered version of the app

  2. Inject the generated HTML into the template

  3. Save the resulting file as index.html

scripts/prerender.tsx

import dotenv from "dotenv";
import fs from "fs";
import path, { dirname } from "node:path";
import { fileURLToPath } from "node:url";
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export async function prerenderHomePage() {
const { render } = await import("../dist/server/main.server.js");
const htmlTemplate = fs.readFileSync(
path.resolve(__dirname, "..", "dist", "index.html"),
{
encoding: "utf-8",
}
);
const rendered = await render();
const html = htmlTemplate.replace(`<!--app-html-->`, rendered.html ?? "");
const outputPath = path.resolve(__dirname, "..", "dist/index.html");
fs.writeFileSync(outputPath, html);
console.log(`Prerendered HTML saved to: ${outputPath}`);
}
prerenderHomePage();

Step 5: Combine Everything

Add the necessary scripts to your package.json for a seamless build process:

package.json

{
// ...
"scripts": {
"dev": "vite dev",
"build": "pnpm build:app && pnpm prerender",
"build:app": "vite build",
"build:server": "vite build --ssr ./src/main.server.tsx --outDir dist/server",
"prerender": "pnpm build:server && tsx ./scripts/prerender.tsx"
},
// ...
}

Run pnpm build to generate both the SPA and prerendered landing page. The result? A traditional SPA with a statically rendered React home page optimized for speed and SEO!


Conclusion

Prerendering offers a lightweight solution to enhance the performance and SEO of your SPA without the complexity of a metaframework. By leveraging React’s server rendering capabilities and a simple build pipeline, you can deliver a fast and indexable landing page while keeping your tech stack minimal and efficient. Give it a try and share your results!