32blogby Studio Mitsu

Why SSR Isn't Working in Next.js App Router (And How to Fix It)

Data not updating, pages cached when they shouldn't be — common App Router SSR issues explained with concrete fixes.

by omitsu10 min read
On this page

The most common reason SSR doesn't work in Next.js App Router: Next.js automatically optimizes pages to static generation (SSG). If your page doesn't use dynamic APIs like cookies() or headers(), it gets pre-rendered as static HTML at build time — no per-request execution. Fix it with export const dynamic = "force-dynamic" or cache: "no-store".

While building 32blog.com, I hit this several times: "The data is updated — why isn't it showing up?" Redeploying didn't help. Clearing caches didn't help.

Almost every time, the cause was a misunderstanding of App Router's rendering strategy. The most common pattern: "I thought I was using SSR, but Next.js silently switched to static generation."

This article walks through the typical patterns where SSR doesn't behave as expected in App Router, with fixes for each.

Understanding App Router Rendering Strategies

First, the foundations. App Router pages run under one of three strategies:

StrategyWhenUse case
Static Generation (SSG)Once at build timeContent that doesn't change
Dynamic Rendering (SSR)On every requestPer-user or real-time data
ISRPeriodically regeneratedContent that updates occasionally

The problem is that Next.js automatically optimizes to static generation when it thinks it can. Pages you intend to be dynamic silently become static.

Does Adding "use client" Enable SSR?

Quick myth-bust first: "use client" has nothing to do with SSR.

"use client" marks a component as a Client Component. It defines Server vs. Client Component boundaries — not "don't run this on the server."

tsx
// Common misconception: "use client" means browser-only
"use client";

import { useState, useEffect } from "react";

export function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch("/api/user").then(r => r.json()).then(setUser);
  }, []);

  if (!user) return <p>Loading...</p>;
  return <p>{user.name}</p>;
}

That's client-side fetching — nothing to do with SSR. Server data isn't fetched. SEO suffers too.

For actual SSR data fetching, write an async Server Component:

tsx
// Server Component data fetching (SSR/SSG)
// No "use client" = Server Component
export default async function UserProfile() {
  // Executes on the server
  const user = await getUser();

  return <p>{user.name}</p>;
}

Why Does Next.js Switch to Static Generation?

Next.js optimizes to static generation when all of these are true:

  1. No dynamic APIs used (cookies(), headers(), etc.)
  2. searchParams is not accessed
  3. No cache-busting fetch options
  4. export const dynamic = "force-dynamic" is not set

A plain Server Component with data fetching looks like "I can execute this at build time and cache it" to Next.js — so it becomes SSG automatically.

How to Check: Read the Build Log

bash
npm run build

The build log shows each page's rendering strategy:

Route (app)                    Size     First Load JS
┌ ○ /                          4.2 kB        120 kB
├ ○ /[locale]/about            2.1 kB        118 kB
├ ƒ /[locale]/dashboard        5.3 kB        121 kB
└ ● /[locale]/blog/[slug]      8.4 kB        124 kB
  • — Static generation (SSG)
  • ƒ — Dynamic rendering (SSR)
  • — ISR (static with revalidation)

If a page shows but you expect it to be dynamic, that's your SSR problem.

Fix 1: force-dynamic for Guaranteed Dynamic Rendering

The simplest fix. Add one export to the page file.

tsx
// app/[locale]/dashboard/page.tsx

// Add this — that's all it takes to force SSR
export const dynamic = "force-dynamic";

export default async function DashboardPage() {
  // Runs on every request
  const data = await fetchDashboardData();

  return <Dashboard data={data} />;
}

Keep in mind that force-dynamic means server processing runs on every request, which increases load. Use it only where truly needed.

Fix 2: Explicitly Disable fetch Caching

Next.js 15+ defaults to no-store, so this is less common now. But if force-cache ends up in your code, it overrides the default.

tsx
// Intended as SSR, but force-cache bakes it in at build time
export default async function PricePage() {
  const prices = await fetch("https://api.example.com/prices", {
    cache: "force-cache", // Cached at build time → never updates
  }).then(r => r.json());

  return <PriceTable prices={prices} />;
}
tsx
// Always fetch fresh data
export default async function PricePage() {
  const prices = await fetch("https://api.example.com/prices", {
    cache: "no-store", // No cache → fetched fresh every request
  }).then(r => r.json());

  return <PriceTable prices={prices} />;
}

Setting no-store on any fetch automatically makes the containing page dynamic. No need to separately set force-dynamic.

Fix 3: Use Dynamic APIs to Trigger SSR

Calling cookies() or headers() tells Next.js that the page must be rendered dynamically.

tsx
// app/[locale]/profile/page.tsx
import { cookies } from "next/headers";

export default async function ProfilePage() {
  // Just calling cookies() makes this page dynamic
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get("session")?.value;

  if (!sessionToken) {
    redirect("/login");
  }

  const user = await getUserFromSession(sessionToken);

  return <Profile user={user} />;
}

This is the standard pattern for authenticated pages. Checking a session cookie forces dynamic rendering naturally.

Fix 4: Accessing searchParams Makes Pages Dynamic

Referencing URL query parameters like ?page=2 automatically triggers dynamic rendering.

tsx
// app/[locale]/blog/page.tsx

interface SearchParams {
  page?: string;
  category?: string;
}

export default async function BlogPage({
  searchParams,
}: {
  searchParams: Promise<SearchParams>;
}) {
  // Accessing searchParams makes this page automatically dynamic
  const { page = "1", category } = await searchParams;
  const posts = await getPosts({ page: Number(page), category });

  return <PostList posts={posts} />;
}

Pages with pagination or filtering naturally become dynamic because they reference searchParams.

Fix 5: revalidateTag for Targeted Cache Invalidation

For ISR patterns, use tagged caching to bust specific cache entries when content updates.

Next.js 16 recommends the use cache directive over unstable_cache, but projects without dynamicIO enabled can still use the older API.

tsx
// app/[locale]/blog/[slug]/page.tsx
import { unstable_cache } from "next/cache";

// Tagged cache (migration to use cache recommended in Next.js 16)
const getPost = unstable_cache(
  async (slug: string) => {
    return fetchPostFromCMS(slug);
  },
  ["post"],
  {
    tags: ["posts"],
    revalidate: 3600, // also auto-revalidate every hour
  }
);

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  return <article>{post.content}</article>;
}
tsx
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest } from "next/server";

export async function POST(request: NextRequest) {
  const { secret } = await request.json();

  if (secret !== process.env.REVALIDATE_SECRET) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  revalidateTag("posts");

  return Response.json({ revalidated: true });
}

When a CMS updates content, hitting /api/revalidate via webhook clears the cache. The next visitor gets fresh data.

generateStaticParams and Dynamic Params

When using generateStaticParams, control what happens for unlisted paths using dynamicParams.

tsx
// app/[locale]/blog/[slug]/page.tsx
import { notFound } from "next/navigation";

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

// Default: true — unlisted slugs are rendered dynamically on first access
export const dynamicParams = true;

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) notFound();

  return <article>{post.content}</article>;
}

With dynamicParams = true (the default), accessing an unlisted slug triggers on-demand SSR. The result is cached for subsequent visitors.

FAQ

Should I use force-dynamic or cache: "no-store"?

Use export const dynamic = "force-dynamic" when you want the entire page to render dynamically. Use cache: "no-store" when you only need to disable caching on a specific fetch call. In practice, a no-store fetch makes the whole page dynamic anyway, so the result is often the same.

Why does my page work dynamically in dev but become static in production?

next dev runs every page on each request, so you can't tell static from dynamic in development. The only reliable way to check is the production build (next build) output, which shows ○/ƒ/● for each route.

How do Server Components and Client Components interact with SSR?

Client Components placed inside Server Components still get pre-rendered on the server and then hydrated on the client. The "use client" directive doesn't mean "browser-only" — it defines the boundary between Server and Client Components. Data fetching for SSR should happen in Server Components.

What's the difference between revalidate and revalidateTag?

revalidate is time-based — cache automatically refreshes after the specified number of seconds. revalidateTag is event-based — you programmatically invalidate specific tagged caches on demand. Use revalidateTag when you want immediate updates triggered by CMS webhooks or similar events.

Why did caching behavior change between Next.js 14 and 15?

In Next.js 14, fetch defaulted to force-cache, meaning everything was cached unless you opted out. Next.js 15 flipped this to no-store, requiring explicit opt-in to caching. If you're upgrading from 14 to 15, pages that relied on implicit caching may see performance regressions — you'll need to add explicit caching where needed.

Is unstable_cache still supported?

It works, but Next.js 16 recommends migrating to the use cache directive. Unlike unstable_cache which only caches JSON data, use cache can cache components and entire routes. Projects without dynamicIO enabled can continue using unstable_cache.

My generateStaticParams pages aren't updating — what do I do?

Pages generated with generateStaticParams are static by default and won't reflect data changes until the next build. To update them, set a revalidate value or use revalidatePath / revalidateTag for on-demand revalidation. With dynamicParams = true, unlisted paths fall back to SSR.

Wrapping Up

Quick reference for SSR issues in App Router:

SymptomCauseFix
Data not updatingPage is statically generatedforce-dynamic or cache: "no-store"
Everyone sees the same pageUser-specific data fetched client-sideUse cookies() / headers() to trigger SSR
Build log shows No dynamic APIs usedAdd dynamic = "force-dynamic"
Old data persists after updateforce-cache setUse revalidate or revalidateTag

When "SSR should be working but isn't," the fastest path to root cause is checking the build log's ○/ƒ/● symbols. From there, the fix becomes obvious.

Related articles: