Server-Side Rendering with Remix: SSR, SSG, ISR
Remix has rapidly become one of the most compelling frameworks for building modern web applications. Built on top of React Router and powered by Web standards like Fetch, Request, and Response, Remix embraces the server as a first-class citizen. While many developers associate Remix primarily with Server-Side Rendering (SSR), the framework is flexible enough to support Static Site Generation (SSG) and Incremental Static Regeneration (ISR)-style patterns as well. In this tutorial, we will explore what each rendering strategy means, why they matter, and how to implement them effectively in a Remix application.
Understanding the Rendering Strategies
Before diving into code, it is important to understand the three rendering strategies we will cover:
- Server-Side Rendering (SSR): The server generates the full HTML for each request. This ensures fresh data on every load and is ideal for highly dynamic, personalized content.
- Static Site Generation (SSG): HTML is generated once at build time and served as static files. This approach is fast, cacheable, and perfect for content that rarely changes.
- Incremental Static Regeneration (ISR): A hybrid approach where static pages are generated on the first request and then revalidated in the background at a specified interval. This combines the speed of SSG with the freshness of SSR.
Remix is opinionated toward SSR by default, but with the right configuration and deployment target, you can achieve SSG and ISR-like behavior. Let's explore each approach in detail.
Setting Up a Remix Project
Let's start by creating a fresh Remix project. We will use the official Remix CLI to scaffold the application.
npx create-remix@latest my-remix-app
cd my-remix-app
npm install
npm run dev
By default, Remix runs in development mode with SSR enabled. The development server listens on http://localhost:3000. Open this URL in your browser to verify the application is running.
Server-Side Rendering (SSR) in Remix
SSR is Remix's default rendering strategy. When a user requests a page, the server executes the route's loader function, fetches the necessary data, renders the React component tree to HTML, and sends the complete document to the browser. This means the user sees fully rendered content immediately, even before JavaScript hydrates on the client.
Writing a Loader for SSR
The loader function is the heart of Remix's data fetching on the server. It runs only on the server, which means you can securely access databases, environment variables, and private APIs without exposing them to the client.
// app/routes/posts.$postId.tsx
import { json, useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
type Post = {
id: number;
title: string;
body: string;
};
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data) return [{ title: "Post not found" }];
return [{ title: data.post.title }];
};
export async function loader({ params }: LoaderFunctionArgs) {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts/${params.postId}`
);
if (!response.ok) {
throw new Response("Post not found", { status: 404 });
}
const post: Post = await response.json();
return json({ post });
}
export default function PostRoute() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
In this example, the loader fetches a post from an external API on every request. The server renders the HTML with the fetched data, so the browser receives a fully populated page. This is pure SSR: every request triggers a fresh data fetch and render.
Adding Caching Headers for Performance
While SSR provides fresh data, it can be expensive if every request hits your database or external APIs. Remix lets you attach HTTP caching headers to responses, allowing CDNs and browsers to cache the rendered output for a specified duration.
export async function loader({ params }: LoaderFunctionArgs) {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts/${params.postId}`
);
if (!response.ok) {
throw new Response("Post not found", { status: 404 });
}
const post: Post = await response.json();
return json(
{ post },
{
headers: {
"Cache-Control": "public, max-age=60, s-maxage=300, stale-while-revalidate=86400",
},
}
);
}
Here, max-age=60 tells the browser to cache the response for 60 seconds, s-maxage=300 tells the CDN to cache it for 5 minutes, and stale-while-revalidate=86400 allows serving stale content for up to a day while revalidating in the background. This pattern effectively gives you ISR-like behavior on top of SSR.
Static Site Generation (SSG) in Remix
While Remix is designed for SSR, you can pre-render pages at build time using the @remix-run/serve adapter with a custom server, or by leveraging the remix build command with a prerendering configuration. Remix provides a built-in way to prerender routes using the build output and a custom script.
Prerendering Routes at Build Time
To achieve SSG, you can write a script that uses Remix's createRequestHandler to generate static HTML files for known routes. Here is a practical example:
// scripts/prerender.ts
import { createRequestHandler } from "@remix-run/node";
import { promises as fs } from "fs";
import path from "path";
import * as build from "../build/server";
const handler = createRequestHandler(build, "production");
const routesToPrerender = [
"/",
"/about",
"/blog",
"/blog/remix-ssr-guide",
"/blog/remix-ssg-guide",
];
async function prerender() {
const outputDir = path.join(process.cwd(), "public", "prerendered");
await fs.mkdir(outputDir, { recursive: true });
for (const route of routesToPrerender) {
const request = new Request(`http://localhost${route}`);
const response = await handler(request);
if (!response.ok) {
console.error(`Failed to prerender ${route}: ${response.status}`);
continue;
}
const html = await response.text();
const filePath = path.join(
outputDir,
route === "/" ? "index.html" : `${route}.html`
);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, html, "utf-8");
console.log(`Prerendered: ${route} -> ${filePath}`);
}
}
prerender().catch(console.error);
You can run this script after building your Remix application:
npm run build
npx tsx scripts/prerender.ts
This generates static HTML files that you can deploy to any static hosting provider like GitHub Pages, Cloudflare Pages, or Amazon S3. The key trade-off is that these pages are frozen at build time, so any data changes require a rebuild.
Using Resource Routes for Static Data
For SSG, you might also want to generate static JSON data files alongside your HTML. Remix resource routes are perfect for this. A resource route is a route that exports a loader but no default component, meaning it returns data instead of HTML.
// app/routes/data.posts.tsx
import { json } from "@remix-run/node";
import type { LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
const posts = await response.json();
return json(posts, {
headers: {
"Cache-Control": "public, max-age=3600",
},
});
}
During prerendering, you can fetch this resource route and save the JSON output to a file, giving you a fully static data layer that your client-side code can consume.
Incremental Static Regeneration (ISR) in Remix
True ISR, as popularized by Next.js, is not a built-in feature of Remix. However, you can achieve ISR-like behavior using a combination of caching headers, CDN-level revalidation, and background data refresh strategies. The key is to serve cached content while periodically updating it in the background.
Implementing ISR with Cache Headers
The most straightforward way to implement ISR in Remix is through HTTP caching headers combined with a CDN that supports stale-while-revalidate. Here is an example for a blog post route:
// app/routes/blog.$slug.tsx
import { json, useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { getPostBySlug } from "~/models/post.server";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPostBySlug(params.slug!);
if (!post) {
throw new Response("Not Found", { status: 404 });
}
return json(
{ post },
{
headers: {
// Serve from CDN cache for up to 1 hour
"Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
},
}
);
}
export default function BlogPost() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.publishedAt}>{post.publishedAt}</time>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}
With this configuration, the first request fetches fresh data and renders the page. The CDN caches the response for one hour (s-maxage=3600). After the cache expires, the CDN serves the stale version for up to 24 hours (stale-while-revalidate=86400) while fetching a fresh copy in the background. This is effectively ISR: users always get a fast response, and content updates propagate within the revalidation window.
Implementing ISR with a Custom Server
For more control, you can implement ISR using a custom server with an in-memory or Redis-based cache. Here is an example using Express and a simple cache layer:
// server.ts
import { createRequestHandler } from "@remix-run/express";
import express from "express";
import { LRUCache } from "lru-cache";
const app = express();
const handler = createRequestHandler({
build: require("./build/server"),
});
const cache = new LRUCache<string, { html: string; timestamp: number }>({
max: 500,
ttl: 1000 * 60 * 60, // 1 hour
});
const REVALIDATION_INTERVAL = 1000 * 60 * 60; // 1 hour
app.get("*", async (req, res) => {
const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
const cached = cache.get(url);
if (cached) {
// Serve cached content immediately
res.send(cached.html);
// Revalidate in background if cache is stale
if (Date.now() - cached.timestamp > REVALIDATION_INTERVAL) {
const request = new Request(url);
const response = await handler(request);
const html = await response.text();
cache.set(url, { html, timestamp: Date.now() });
}
return;
}
// No cache: render fresh and store
const request = new Request(url, {
headers: req.headers as any,
method: req.method,
});
const response = await handler(request);
const html = await response.text();
cache.set(url, { html, timestamp: Date.now() });
res.send(html);
});
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});
This custom server caches rendered HTML in memory using an LRU cache. When a request comes in, it serves the cached version immediately and revalidates in the background if the cache is older than the revalidation interval. This gives you full control over the ISR behavior without relying on a CDN.
Best Practices
Choose the Right Strategy for Each Route
Not every route needs the same rendering strategy. A marketing landing page might benefit from SSG for maximum performance, while a user dashboard requires SSR for personalized, real-time data. Remix's per-route architecture makes it easy to mix strategies within a single application.
Use Caching Headers Strategically
HTTP caching headers are your most powerful tool for performance. Always set appropriate Cache-Control headers on your loader responses. For public, non-personalized content, use public with s-maxage for CDN caching. For personalized content, use private with a short max-age or no caching at all.
Handle Errors Gracefully
When loaders throw errors, Remix automatically renders the nearest ErrorBoundary. Make sure to export an ErrorBoundary component in your routes to provide a good user experience when data fetching fails.
// app/routes/posts.$postId.tsx
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return <h1>Something went wrong</h1>;
}
Optimize Data Fetching
Avoid making redundant API calls in your loaders. If multiple routes need the same data, consider fetching it once in a parent route's loader and passing it down through the outlet context. Also, use Promise.all to parallelize independent data fetches.
export async function loader({ params }: LoaderFunctionArgs) {
const [post, comments, author] = await Promise.all([
fetchPost(params.postId),
fetchComments(params.postId),
fetchAuthor(params.postId),
]);
return json({ post, comments, author });
}
Leverage Remix's Nested Routing
Remix's nested routing allows partial revalidation. When a user navigates within a nested route, only the changed segments re-fetch data. This means you can structure your application so that static parent layouts never re-render while dynamic child routes update independently. This is a natural performance optimization that works alongside your caching strategy.
Monitor and Measure Performance
Use tools like Lighthouse, WebPageTest, and Remix's built-in performance metrics to measure your application's rendering performance. Pay attention to Time to First Byte (TTFB), First Contentful Paint (FCP), and Largest Contentful Paint (LCP). These metrics will help you identify which routes need better caching or optimization.
Conclusion
Remix provides a powerful and flexible foundation for building web applications with a variety of rendering strategies. Its default SSR approach ensures that users always receive fresh, fully rendered content, while HTTP caching headers enable ISR-like behavior with minimal configuration. For content that changes infrequently, prerendering scripts can achieve true SSG, giving you the best of both worlds within a single framework. By understanding the trade-offs between SSR, SSG, and ISR, and by applying best practices like strategic caching, graceful error handling, and optimized data fetching, you can build Remix applications that are fast, reliable, and maintainable. The key is to match each route's rendering strategy to its content's needs, letting Remix's architecture do the heavy lifting while you focus on delivering a great user experience.