Server-Side Rendering with React Router: SSR, SSG, ISR
Modern web applications demand fast initial page loads, SEO-friendly content, and rich interactivity. React Router has evolved beyond a simple client-side navigation library into a full-stack framework capable of handling Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR). In this tutorial, we'll explore how to leverage React Router's data APIs to build performant, render-strategy-aware applications.
Understanding the Rendering Strategies
Before diving into code, it's essential to understand the three primary rendering strategies and when to use each:
- SSR (Server-Side Rendering): HTML is generated on the server for each request. Ideal for personalized, dynamic content that changes frequently or depends on user authentication.
- SSG (Static Site Generation): HTML is generated once at build time and reused for every request. Perfect for content that rarely changes, like blog posts or marketing pages.
- ISR (Incremental Static Regeneration): A hybrid approach where static pages are regenerated in the background at a configurable interval. Great for content that updates periodically but doesn't need real-time freshness.
Why Rendering Strategy Matters
Choosing the right strategy directly impacts performance, cost, and user experience. SSR ensures users always see fresh content but adds server load and latency. SSG delivers the fastest possible page loads with minimal infrastructure but requires rebuilds for content changes. ISR bridges the gap by serving stale content immediately while refreshing it in the background. React Router's loader pattern makes it straightforward to adopt all three within a single application, allowing you to optimize each route independently.
Setting Up the Project
Let's start by creating a new React Router project. We'll use Vite for the build tooling and React Router's framework mode, which provides built-in SSR support.
# Create a new project
npx create-react-router@latest my-ssr-app
cd my-ssr-app
npm install
The project structure will look something like this:
my-ssr-app/
βββ app/
β βββ root.tsx
β βββ routes/
β β βββ _index.tsx
β β βββ blog.$slug.tsx
β β βββ dashboard.tsx
β βββ entry.server.tsx
βββ public/
βββ vite.config.ts
βββ package.json
Configuring the Server Entry
The server entry file handles rendering your React application on the server. React Router provides a default implementation, but understanding it helps when customizing rendering strategies.
// app/entry.server.tsx
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { renderToPipeableStream } from "react-dom/server";
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext
) {
return new Promise((resolve, reject) => {
const { pipe } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
onShellReady() {
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
console.error(error);
},
}
);
});
}
Implementing SSR with Loaders
SSR in React Router is powered by the loader function exported from each route module. The loader runs on the server before the component renders, fetching data and passing it to the component as props via the useLoaderData hook.
// app/routes/dashboard.tsx
import { useLoaderData } from "react-router";
import type { LoaderFunctionArgs } from "react-router";
type UserStats = {
totalOrders: number;
recentActivity: Activity[];
};
type Activity = {
id: string;
description: string;
timestamp: string;
};
export async function loader({ request }: LoaderFunctionArgs) {
// This runs on the server for every request
const cookie = request.headers.get("Cookie");
const userResponse = await fetch("https://api.example.com/me", {
headers: { Cookie: cookie || "" },
});
if (!userResponse.ok) {
throw new Response("Unauthorized", { status: 401 });
}
const user = await userResponse.json();
const statsResponse = await fetch(
`https://api.example.com/users/${user.id}/stats`
);
const stats: UserStats = await statsResponse.json();
return { user, stats };
}
export default function Dashboard() {
const { user, stats } = useLoaderData<typeof loader>();
return (
<div>
<h1>Welcome, {user.name}</h1>
<p>Total Orders: {stats.totalOrders}</p>
<ul>
{stats.recentActivity.map((activity) => (
<li key={activity.id}>
{activity.description} - {activity.timestamp}
</li>
))}
</ul>
</div>
);
}
Because the loader runs on the server, the initial HTML sent to the browser already contains the fully rendered content. This means search engines can index the page, and users see content immediately without waiting for client-side JavaScript to load and fetch data.
Implementing SSG for Static Content
For content that doesn't change often, SSG is far more efficient. React Router supports prerendering through its build configuration. You specify which routes should be prerendered, and the build process generates static HTML files for them.
// vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
reactRouter({
prerender: {
// Prerender specific paths
paths: ["/", "/about", "/blog/getting-started"],
// Or use a function to dynamically determine paths
// async paths({ request }) { ... }
},
}),
],
});
For dynamic routes like blog posts, you'll want to prerender all available slugs. You can do this with an async function:
// vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
async function getAllBlogSlugs(): Promise<string[]> {
const response = await fetch("https://api.example.com/posts/slugs");
const slugs: string[] = await response.json();
return slugs.map((slug) => `/blog/${slug}`);
}
export default defineConfig({
plugins: [
reactRouter({
prerender: {
async paths() {
const blogSlugs = await getAllBlogSlugs();
return ["/", "/about", "/blog", ...blogSlugs];
},
},
}),
],
});
The route module for a blog post uses a loader just like SSR, but when prerendered, the loader runs at build time instead of request time:
// app/routes/blog.$slug.tsx
import { useLoaderData } from "react-router";
import type { LoaderFunctionArgs } from "react-router";
type BlogPost = {
title: string;
content: string;
publishedAt: string;
author: string;
};
export async function loader({ params }: LoaderFunctionArgs) {
const { slug } = params;
const response = await fetch(`https://api.example.com/posts/${slug}`);
if (!response.ok) {
throw new Response("Not Found", { status: 404 });
}
const post: BlogPost = await response.json();
return { post };
}
export default function BlogPost() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author} on {post.publishedAt}</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
Implementing ISR for Periodically Updated Content
ISR combines the speed of SSG with the freshness of SSR. React Router doesn't have a built-in ISR API like Next.js, but you can implement it using a combination of caching headers, a CDN, and a background regeneration mechanism. The key idea is to serve cached content with a stale-while-revalidate strategy.
Here's how to implement ISR using cache headers in your loader's response:
// app/routes/blog.$slug.tsx (ISR version)
import { useLoaderData } from "react-router";
import type { LoaderFunctionArgs } from "react-router";
type BlogPost = {
title: string;
content: string;
publishedAt: string;
author: string;
};
// Cache duration in seconds (e.g., 1 hour)
const REVALIDATE_SECONDS = 3600;
export async function loader({ params, request }: LoaderFunctionArgs) {
const { slug } = params;
const response = await fetch(`https://api.example.com/posts/${slug}`);
if (!response.ok) {
throw new Response("Not Found", { status: 404 });
}
const post: BlogPost = await response.json();
// Set cache headers for ISR behavior
const headers = new Headers();
headers.set(
"Cache-Control",
`public, max-age=0, s-maxage=${REVALIDATE_SECONDS}, stale-while-revalidate=${REVALIDATE_SECONDS * 2}`
);
return new Response(JSON.stringify({ post }), {
headers: {
...Object.fromEntries(headers),
"Content-Type": "application/json",
},
});
}
export default function BlogPost() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author} on {post.publishedAt}</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
For a more robust ISR implementation, you can use an on-demand revalidation endpoint that triggers a rebuild or cache purge when content changes:
// app/routes/api.revalidate.ts
import type { ActionFunctionArgs } from "react-router";
export async function action({ request }: ActionFunctionArgs) {
const { secret, slug } = await request.json();
// Verify the request is authorized
if (secret !== process.env.REVALIDATION_SECRET) {
return new Response("Unauthorized", { status: 401 });
}
// Purge the cache for this specific slug
// This depends on your CDN/hosting provider
await purgeCache(`/blog/${slug}`);
return new Response(JSON.stringify({ revalidated: true }), {
headers: { "Content-Type": "application/json" },
});
}
Combining Strategies in a Single App
One of the most powerful aspects of React Router is that you can mix rendering strategies per route. Your marketing pages can be statically generated, your dashboard can use SSR, and your blog can use ISRβall within the same application.
// vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
reactRouter({
// Prerender only static routes
prerender: {
paths: ["/", "/about", "/pricing", "/contact"],
},
}),
],
});
Routes not listed in the prerender configuration will be rendered on-demand via SSR. This gives you fine-grained control over which pages are static and which are dynamic.
Handling Meta Tags for SEO
Regardless of your rendering strategy, proper SEO requires meta tags to be present in the server-rendered HTML. React Router's meta export handles this elegantly:
// app/routes/blog.$slug.tsx
import type { MetaFunction } from "react-router";
type LoaderData = {
post: {
title: string;
content: string;
publishedAt: string;
author: string;
};
};
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data) {
return [{ title: "Post Not Found" }];
}
const { post } = data;
return [
{ title: `${post.title} | My Blog` },
{ name: "description", content: post.content.substring(0, 160) },
{ property: "og:title", content: post.title },
{ property: "og:type", content: "article" },
{ property: "og:author", content: post.author },
{ name: "twitter:card", content: "summary_large_image" },
];
};
Best Practices
- Choose the right strategy per route: Don't default to SSR for everything. Audit your routes and use SSG for static content, SSR for personalized data, and ISR for periodically updated pages.
- Keep loaders fast: Server-side loaders directly impact Time to First Byte (TTFB). Use caching, connection pooling, and parallel data fetching to minimize latency.
- Handle errors gracefully: Use Error Boundaries and the
errorElementproperty to provide meaningful fallbacks when loaders fail. - Validate environment variables: Ensure your server has access to all required environment variables at runtime, especially for SSR routes that depend on external APIs.
- Use streaming where possible: React 18's streaming SSR allows you to send HTML chunks as they become ready, improving perceived performance for complex pages.
- Test hydration: Mismatched content between server and client renders causes hydration errors. Avoid using
Date.now(),Math.random(), or browser-only APIs during initial render. - Monitor cache hit rates: For ISR implementations, track cache hit and miss rates to tune your revalidation intervals appropriately.
Debugging Common Issues
When working with SSR, you may encounter hydration mismatches. These occur when the server-rendered HTML differs from what the client expects. Here's a pattern to safely handle browser-only logic:
import { useEffect, useState } from "react";
export default function ClientOnlyComponent() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return <div>Loading...</div>;
}
return <div>{window.innerWidth}px wide</div>;
}
For loader performance issues, add timing logs to identify bottlenecks:
export async function loader({ params }: LoaderFunctionArgs) {
const start = Date.now();
const data = await fetchData(params.id);
console.log(`Loader took ${Date.now() - start}ms`);
return data;
}
Conclusion
React Router's data APIs provide a flexible foundation for implementing SSR, SSG, and ISR within a single application. By understanding the trade-offs of each rendering strategy and applying them judiciously per route, you can build applications that are fast, SEO-friendly, and maintainable. Start by identifying which routes need real-time data versus static content, then configure your loaders and build settings accordingly. As your application grows, monitor performance metrics and adjust your caching strategies to ensure optimal user experiences across all pages.