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:
Slow Initial Rendering: Rendering content on the client-side delays the time to first meaningful paint
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:
SPA Build: The standard client-side application
SSR Build: A server-side bundle for prerendering
Run the following commands for the respective builds:
vite buildvite 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:
Load the server-rendered version of the app
Inject the generated HTML into the template
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!