32blogby Studio Mitsu

Next.js SSR Guide: What Changed with Server Components

How SSR works in App Router with Server Components. Practical patterns for data fetching, caching, and streaming in Next.js 16.

by omitsu12 min read
On this page

In Next.js App Router, SSR works through Server Components — async components that run on the server by default. There's no more getServerSideProps. You write data fetching directly inside components, control caching with fetch options or the use cache directive, and stream HTML progressively with Suspense.

When I built 32blog.com with Next.js 16's App Router, the biggest mental shift was rethinking SSR from scratch. Pages Router's getServerSideProps and getStaticProps were familiar territory — when Server Components arrived, figuring out the new patterns took real effort. This article covers all of it in one place.

By the end, you'll know how to decide between SSR and SSG in App Router, where to put your data fetching, and how to control caching behavior. If you're debugging SSR issues specifically, check out the troubleshooting guide as well.

How Did SSR Change with App Router?

In Pages Router, SSR meant using getServerSideProps — a special function that runs server-side processing on every request.

tsx
// Pages Router (old way)
// pages/posts/[id].tsx
export async function getServerSideProps(context) {
  const { id } = context.params;
  const post = await fetchPost(id);

  return {
    props: { post },
  };
}

export default function PostPage({ post }) {
  return <article>{post.title}</article>;
}

In App Router, this pattern changed fundamentally. Components themselves can be async.

tsx
// App Router (current)
// app/posts/[id]/page.tsx
async function fetchPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`);
  return res.json();
}

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

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

You can write await directly inside the component. The indirect pattern of passing props through getServerSideProps is gone — data fetching and UI live in the same file.

The core shift is that components now run on the server by default. Server Components execute server-side only. You can write database queries or API calls directly, and that code never gets sent to the browser. For a deeper dive into how this architecture works under the hood, see React Server Components Explained.

How to Decide Between Server and Client Components

The decision rule is simple:

FeatureServer ComponentClient Component
useState / useEffect
Browser APIs (localStorage, etc.)
Direct database access
API keys (never exposed to client)
Click events and interactions
SEO-critical content

Typical Server Component Pattern

tsx
// app/components/ArticleList.tsx
// No "use client" = Server Component

import { getArticles } from "@/lib/content";

export async function ArticleList({ category }: { category: string }) {
  // This only runs on the server
  // API keys are safe to use directly here
  const articles = await getArticles(category);

  return (
    <ul>
      {articles.map((article) => (
        <li key={article.slug}>
          <a href={`/en/${category}/${article.slug}`}>{article.title}</a>
        </li>
      ))}
    </ul>
  );
}

Keep Client Components at the Leaf Level

tsx
// app/components/LikeButton.tsx
"use client";

import { useState } from "react";

// Only the minimum interactive unit becomes a Client Component
export function LikeButton({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  const [liked, setLiked] = useState(false);

  const handleLike = async () => {
    if (liked) return;
    setCount((c) => c + 1);
    setLiked(true);
    await fetch("/api/likes", { method: "POST" });
  };

  return (
    <button onClick={handleLike} aria-pressed={liked}>
      {liked ? "❤️" : "🤍"} {count}
    </button>
  );
}
tsx
// app/posts/[id]/page.tsx (Server Component)
import { LikeButton } from "@/components/LikeButton";

export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const post = await fetchPost(id);
  const likeCount = await getLikeCount(id);

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      {/* Embed Client Component inside Server Component */}
      <LikeButton initialCount={likeCount} />
    </article>
  );
}

How to Control fetch Caching

In App Router, fetch was cached by default (before Next.js 15). Since Next.js 15, the default changed to no-cache (no-store). Since 32blog.com runs on Next.js 16, the default is no cache.

tsx
// Next.js 15+ — fetch defaults to no-store
const res = await fetch("https://api.example.com/posts");

// Explicitly opt into SSG (fetch once at build time)
const res = await fetch("https://api.example.com/posts", {
  cache: "force-cache",
});

// Regenerate at a set interval (ISR equivalent)
const res = await fetch("https://api.example.com/posts", {
  next: { revalidate: 3600 }, // recheck every hour
});

// Always fresh (SSR equivalent)
const res = await fetch("https://api.example.com/posts", {
  cache: "no-store",
});

These are options on the fetch function. For data fetching that doesn't use fetch — like direct Prisma database queries — you need the use cache directive instead. This became stable in Next.js 16.

tsx
import { cacheLife, cacheTag } from "next/cache";

async function getRecentPosts(category: string) {
  "use cache";
  cacheLife("hours"); // cache for 1 hour
  cacheTag("posts");

  // Direct DB query — no fetch involved
  return await db.post.findMany({
    where: { category },
    orderBy: { createdAt: "desc" },
    take: 10,
  });
}

The old unstable_cache API still works but is considered legacy — use cache is the recommended replacement. You can invalidate cached data from a Server Action using revalidateTag().

Set Caching per Route Segment

Route segment config lets you set caching behavior for an entire page file.

tsx
// app/posts/[id]/page.tsx — Force static generation (SSG equivalent)
export const dynamic = "force-static";
tsx
// app/posts/[id]/page.tsx — Force dynamic rendering (SSR equivalent)
export const dynamic = "force-dynamic";
tsx
// ISR equivalent: regenerate every 60 seconds
export const revalidate = 60;

32blog.com uses generateStaticParams to statically generate all article pages at build time.

tsx
// app/[locale]/[category]/[slug]/page.tsx

// Generate all article paths at build time
export async function generateStaticParams() {
  const locales = ["ja", "en", "es"];
  const categories = [
    "nextjs", "react", "vercel", "security",
    "claude-code", "ffmpeg", "cli", "gaming",
    "yocto", "renpy", "business",
  ];

  const paths = [];
  for (const locale of locales) {
    for (const category of categories) {
      const articles = await getArticlesByCategory(category, locale);
      for (const article of articles) {
        paths.push({
          locale,
          category,
          slug: article.slug,
        });
      }
    }
  }
  return paths;
}

For a full walkthrough of build-time optimization techniques including generateStaticParams, see Next.js Build Optimization.

What Does Suspense + Streaming Change?

Pages Router SSR worked by sending the full HTML only after everything finished rendering. App Router supports streaming. Combined with Suspense, you can send HTML progressively as each part becomes ready.

tsx
// app/posts/[id]/page.tsx
import { Suspense } from "react";
import { PostContent } from "@/components/PostContent";
import { RelatedPosts } from "@/components/RelatedPosts";
import { CommentSection } from "@/components/CommentSection";

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

  return (
    <main>
      {/* Post content appears first (fast API) */}
      <Suspense fallback={<PostContentSkeleton />}>
        <PostContent id={id} />
      </Suspense>

      {/* Related posts come later (separate, slower API) */}
      <Suspense fallback={<RelatedPostsSkeleton />}>
        <RelatedPosts id={id} />
      </Suspense>

      {/* Comments load last (heaviest operation) */}
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentSection id={id} />
      </Suspense>
    </main>
  );
}

The browser receives the first HTML chunk as soon as PostContent is ready. Related posts and comments stream in afterward. Users don't wait for everything to load before seeing the main content.

The practical benefit is improved LCP (Largest Contentful Paint). Getting the most important content — the article itself — to users faster reduces the time they perceive as "nothing is loading." If you've run into hydration mismatches when mixing server-streamed and client-rendered content, the hydration error fix guide covers common causes.

When to Use SSG vs SSR

Here's a quick decision guide:

CaseRecommendationReason
Blog posts (static content)SSG (static generation)Fast, CDN cacheable
Product listings (changing inventory)ISR (revalidate)Cache matches update frequency
User profile pagesSSR (dynamic)Different per user
Real-time dashboardsCSR (Client Component)Server rendering not needed

32blog.com uses SSG for article pages and the homepage (article list generated at build time). Search is implemented as a Client Component.

tsx
// app/[locale]/page.tsx
// Homepage — statically generated

export async function generateStaticParams() {
  return [{ locale: "ja" }, { locale: "en" }, { locale: "es" }];
}

export default async function HomePage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  // Read MDX files at build time to generate article list
  const latestPosts = await getLatestPosts(locale, 6);
  const categories = await getCategories(locale);

  return (
    <main>
      <HeroSection />
      <ArticleGrid posts={latestPosts} />
      <CategoryList categories={categories} />
    </main>
  );
}

How to Handle Form Processing with Server Actions

App Router introduces Server Actions — server-side processing you can run directly from form submissions without creating an API route.

tsx
// app/contact/page.tsx
export default function ContactPage() {
  async function submitContact(formData: FormData) {
    "use server"; // This function runs on the server

    const name = formData.get("name") as string;
    const email = formData.get("email") as string;
    const message = formData.get("message") as string;

    // Server-side processing (send email, save to DB, etc.)
    await sendEmail({ name, email, message });
  }

  return (
    <form action={submitContact}>
      <input name="name" type="text" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  );
}

The biggest benefit is keeping API keys and database connections off the client. For real-time validation feedback, combine with useActionState (React 19+ — renamed from the deprecated useFormState) and useFormStatus.

FAQ

Do I still need getServerSideProps in App Router?

No. In App Router, getServerSideProps doesn't exist. Server Components are async by default, so you write data fetching directly inside the component. To force dynamic (per-request) rendering, set export const dynamic = "force-dynamic" or use headers() / cookies() which automatically opt the route into dynamic rendering.

Can I use useState or useEffect in a Server Component?

No. React hooks like useState, useEffect, and useRef only work in Client Components (files with "use client" at the top). If you need interactivity, extract that piece into a separate Client Component and import it from the Server Component. See Minimize "use client" for practical patterns.

How do I cache database queries that don't use fetch?

Use the use cache directive (stable in Next.js 16). Add "use cache" at the top of your async function, then control duration with cacheLife() and invalidation with cacheTag(). The older unstable_cache API still works but is no longer recommended.

What's the difference between force-dynamic and cache: "no-store"?

force-dynamic is a route segment config that affects the entire page — it forces dynamic rendering and sets all fetch requests to no-store. Setting cache: "no-store" on an individual fetch only affects that specific request; the rest of the page may still be statically rendered if no other dynamic signals are present.

Does "use client" mean the component only runs in the browser?

No. A "use client" component still gets server-rendered (HTML) on the initial request — it's not invisible to search engines. The difference is that its JavaScript is included in the browser bundle for hydration, so hooks and event handlers work after the page loads. See the Next.js rendering docs for details.

How does streaming affect SEO?

Search engine crawlers receive the fully rendered HTML — streaming is transparent to them. The Googlebot renders JavaScript and waits for content, so streamed Server Components are indexed normally. Streaming primarily improves user-perceived performance (LCP) rather than changing what crawlers see.

When should I use a Server Action vs an API route?

Use Server Actions for form submissions and mutations triggered from the UI — they integrate naturally with React's form handling and useActionState. Use Route Handlers when you need a standalone API endpoint consumed by external clients, webhooks, or mobile apps.

Wrapping Up

The App Router's SSR approach takes getting used to — especially the idea that "components run on the server by default." Here's the summary:

  • Server Component — the default. Write data fetching directly in the component. async/await works natively
  • Client Component — add "use client". Hooks and browser APIs work here. Code is included in the browser bundle
  • Caching — Next.js 15+ defaults to no-cache. Explicitly set force-cache or revalidate when needed
  • Streaming — wrap components in Suspense to send HTML progressively as each part becomes ready
  • SSG vs SSR — decide based on how often content changes and whether it varies per user

32blog.com uses this design to statically generate all pages at build time, serving them from CDN after deployment. Response time stays consistent regardless of how many articles we add — that's the real strength of static generation.