Server-Side Rendering with Turbopack: SSR, SSG, and ISR Explained
Next.js 15 ships Turbopack as the default bundler for development, and it is increasingly capable in production builds. Turbopack is a Rust-based incremental bundler built by the team behind Webpack, designed to deliver dramatically faster startup and Hot Module Replacement (HMR). When combined with Next.js's rendering model, Turbopack changes how quickly you can iterate on Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) features.
This tutorial walks through what each rendering strategy means, why Turbopack matters for them, and how to implement all three in a single Next.js App Router project. You will finish with a working understanding of when to reach for each strategy and how to configure them correctly.
What Is Turbopack?
Turbopack is Next.js's successor to Webpack. It performs fine-grained caching at the function level, compiles only what changes, and is written in Rust for single-threaded speed. For SSR workflows, this matters because every route change in development triggers server compilation. With Webpack, large apps could take seconds to compile a new route; Turbopack typically does it in milliseconds.
As of Next.js 15, Turbopack is stable for development and is being rolled out for production builds behind a flag. You can opt in by adding the following to your next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Enable Turbopack for production builds (when supported by your version)
// Otherwise it is used automatically in `next dev --turbo`
experimental: {
turbopack: {
rules: {
"*.svg": {
loaders: ["@svgr/webpack"],
as: "*.js",
},
},
},
},
};
export default nextConfig;
For day-to-day development, simply run next dev --turbo (or rely on the default in Next.js 15+) to get Turbopack-powered SSR compilation.
Why Rendering Strategy Matters
Choosing the right rendering strategy affects three things: time to first byte (TTFB), SEO crawlability, and how stale your content can be. The three core strategies in Next.js are:
- SSR — HTML is generated on every request. Best for personalized or frequently changing data.
- SSG — HTML is generated once at build time. Best for content that rarely changes and must be fast and cacheable at the edge.
- ISR — HTML is generated at build time and then regenerated in the background at a configurable interval. Best for content that updates periodically but does not need to be real-time.
With Turbopack, the iteration loop for all three is faster. You can switch a route from SSG to SSR, see the result instantly, and tune caching behavior without long recompilation pauses.
Setting Up the Project
Create a fresh Next.js app with the App Router enabled:
npx create-next-app@latest rendering-demo
cd rendering-demo
npm run dev
Confirm that next dev prints a Turbopack banner. If you are on an older version, run next dev --turbo explicitly. The App Router uses React Server Components by default, which means most of your pages are already server-rendered. The strategies below control when that rendering happens.
Implementing SSG (Static Site Generation)
SSG is the default in the App Router. Any page that does not use dynamic functions like cookies(), headers(), or searchParams is statically rendered at build time. To fetch data at build time, use an async Server Component:
// app/blog/page.tsx
import { notFound } from "next/navigation";
type Post = { id: number; title: string; body: string };
async function getPosts(): Promise<Post[]> {
const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=10", {
// SSG: cache forever at build time
cache: "force-cache",
});
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json();
}
export default async function BlogPage() {
const posts = await getPosts();
if (posts.length === 0) notFound();
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/blog/${post.id}`}>{post.title}</a>
</li>
))}
</ul>
</main>
);
}
For dynamic SSG routes, export generateStaticParams to pre-render known paths at build time:
// app/blog/[id]/page.tsx
type Post = { id: number; title: string; body: string };
async function getPost(id: string): Promise<Post> {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
cache: "force-cache",
});
if (!res.ok) return null;
return res.json();
}
export async function generateStaticParams() {
const posts = await fetch(
"https://jsonplaceholder.typicode.com/posts?_limit=10"
).then((r) => r.json());
return posts.map((post: Post) => ({ id: String(post.id) }));
}
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPost(id);
if (!post) return <p>Not found</p>;
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
After running next build, these pages are emitted as static HTML files. Turbopack compiles each route in parallel during the build, which noticeably reduces total build time on larger apps.
Implementing SSR (Server-Side Rendering)
SSR renders HTML on every request. To opt a route into SSR, either call a dynamic function or set dynamic = "force-dynamic". The cleanest approach is to disable fetch caching for the request:
// app/dashboard/page.tsx
type Stats = { users: number; revenue: number; updatedAt: string };
async function getStats(): Promise<Stats> {
const res = await fetch("https://api.example.com/stats", {
// SSR: always fetch fresh data on every request
cache: "no-store",
});
if (!res.ok) throw new Error("Failed to fetch stats");
return res.json();
}
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const stats = await getStats();
return (
<main>
<h1>Dashboard</h1>
<p>Users: {stats.users}</p>
<p>Revenue: ${stats.revenue}</p>
<p>Updated: {stats.updatedAt}</p>
</main>
);
}
Use SSR when the page content depends on the authenticated user, on time-sensitive data, or on request-specific inputs like searchParams. Because Turbopack recompiles server components in milliseconds, you can iterate on SSR routes as quickly as client components.
Implementing ISR (Incremental Static Regeneration)
ISR gives you the speed of static HTML with the freshness of server rendering. The page is generated at build time, served instantly, and then regenerated in the background after a configurable time-to-live (TTL). To enable ISR, pass next.revalidate to fetch or export a revalidate value from the page:
// app/news/page.tsx
type Article = { id: number; title: string; url: string };
async function getArticles(): Promise<Article[]> {
const res = await fetch("https://api.example.com/news", {
// ISR: regenerate at most once every 60 seconds
next: { revalidate: 60 },
});
if (!res.ok) throw new Error("Failed to fetch news");
return res.json();
}
// Route-level fallback: revalidate every 60s even without fetch options
export const revalidate = 60;
export default async function NewsPage() {
const articles = await getArticles();
return (
<main>
<h1>Latest News</h1>
<ul>
{articles.map((a) => (
<li key={a.id}>
<a href={a.url}>{a.title}</a>
</li>
))}
</ul>
</main>
);
}
For dynamic ISR routes, combine generateStaticParams with revalidate:
// app/news/[slug]/page.tsx
type Article = { slug: string; title: string; body: string };
async function getArticle(slug: string): Promise<Article | null> {
const res = await fetch(`https://api.example.com/news/${slug}`, {
next: { revalidate: 60, tags: [`article-${slug}`] },
});
if (!res.ok) return null;
return res.json();
}
export const revalidate = 60;
export async function generateStaticParams() {
const articles = await fetch("https://api.example.com/news").then((r) =>
r.json()
);
return articles.map((a: Article) => ({ slug: a.slug }));
}
export default async function ArticlePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article = await getArticle(slug);
if (!article) return <p>Article not found</p>;
return (
<article>
<h1>{article.title}</h1>
<p>{article.body}</p>
</article>
);
}
On-Demand Revalidation
Sometimes a TTL is not enough — for example, when an editor publishes a new article and you want it live immediately. Use tag-based revalidation with a route handler:
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const body = await req.json();
const secret = req.headers.get("x-revalidate-secret");
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
if (body.tag) {
revalidateTag(body.tag);
return NextResponse.json({ ok: true, revalidated: body.tag });
}
return NextResponse.json({ ok: false, error: "Missing tag" }, { status: 400 });
}
When your CMS publishes an article, send a POST request to /api/revalidate with { "tag": "article-my-slug" } and the secret header. Next.js will regenerate that page on the next request and cache the fresh version.
Best Practices
- Default to SSG. Static is fastest and cheapest. Only move to SSR or ISR when data freshness requires it.
- Use ISR before SSR. If content updates every few minutes, a 60-second revalidate is usually better than per-request rendering.
- Tag your fetches. Always pass
next: { tags: [...] }so you can invalidate precisely withrevalidateTaginstead of clearing the whole cache. - Avoid dynamic functions in static routes. Calling
cookies()orheaders()silently opts the route into SSR. Move those calls into a client component or a dedicated dynamic route. - Measure with Turbopack traces. Run
next build --turbopackand inspect the output to find slow routes. Turbopack's per-route timing helps you spot data-fetching bottlenecks. - Cache at the edge. Pair ISR with
Cache-Controlheaders or a CDN in front of your origin so regenerated pages are served globally with low latency. - Handle errors gracefully. If a background ISR regeneration fails, Next.js keeps serving the previous cached version. Make sure your fetch wrappers throw only on truly fatal errors so transient failures do not break the page.
- Keep Server Components lean. Move heavy client-side interactivity into Client Components with the
"use client"directive. The server-rendered shell stays fast, and Turbopack only recompiles the client island that changed.
Conclusion
Turbopack does not change the rendering model of Next.js — SSR, SSG, and ISR work exactly the same way at runtime — but it transforms the developer experience around them. Faster compilation means you can experiment with caching strategies, switch a route from static to dynamic, and tune revalidation intervals without the friction that used to slow teams down. Start with SSG for everything, reach for ISR when content updates periodically, and reserve SSR for truly per-request data. Combined with Turbopack's speed and Next.js's granular caching primitives, you get a rendering pipeline that is both performant for users and pleasant to build with.