← Back to DevBytes

Server-Side Rendering with React Router: SSR, SSG, ISR

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:

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

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.

β€” Ad β€”

Google AdSense will appear here after approval

← Back to all articles