32blogby StudioMitsu
nextjs9 min read

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.

nextjsSSRServer ComponentsApp Routerdata fetchingcaching
On this page

When I built 32blog.com with Next.js 16's App Router, the biggest mental shift was rethinking SSR (Server-Side Rendering) from scratch.

I was used to Pages Router's getServerSideProps and getStaticProps. When App Router introduced Server Components, I spent a lot of time figuring out "how do I actually use these?" This article answers all those questions 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.

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.

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 — caching works differently.

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"];
  const categories = ["nextjs", "react", "claude-code", "ffmpeg", "gaming"];

  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;
}

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."

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" }];
}

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+) and useFormStatus.

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.