Home / Articles / Modern Next.js Mapped: What Each Feature Replaces and When to Use It

This article is published in English.

Modern Next.js Mapped: What Each Feature Replaces and When to Use It

A guided tour of eight Next.js capabilities, from Server Components to the Metadata API, showing which old pattern each one retires and where it can trip you up.

3113 words

Next.js now owns routing, data fetching, caching, rendering strategy and much of your API surface. Yet teams often adopt it and keep old habits: fetching in useEffect, hand-rolled API routes for every form, caching rules nobody can explain. This guide covers eight capabilities, the older pattern each replaces, and the details that break in real projects, so you can decide feature by feature what belongs in your codebase.

Why the framework now covers the whole stack

Walmart, Nike, TikTok, OpenAI and Airbnb run web applications on Next.js, and the draw is consolidation: routing, bundling, rendering modes, data access and server endpoints share one repository and one set of conventions. Choosing a router and wiring a bundler are no longer part of starting a project, so the time goes into the product instead.

1. Server Components make the server the default place to render

React Server Components (RSC) are the largest architectural change in this list. A Server Component executes only on the server and sends rendered output to the browser, so its code never becomes part of the client JavaScript bundle.

What disappears from a data-driven page

In classic client-rendered React, even a component that only reads data and prints a list adds its code, its fetching logic and its dependencies to the bundle, then hydrates in the browser. With RSC, the component reads data on the server and ships only the result. In the App Router, every component is a Server Component unless you say otherwise, which is why the file below needs no directive at all:

// app/products/page.tsx
// This component runs ONLY on the server. No "use client" directive needed.

The page is an async function that queries the database directly and returns markup. There is no API route in between and no client-side request:

import { db } from "@/lib/db";export default async function ProductsPage() {
  // Direct database access. No API route. No fetch boilerplate.
  const products = await db.product.findMany({ take: 20 });  return (
    <main>
      <h1>Our Products</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            <h2>{product.name}</h2>
            <p>${product.price}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

No useState, no useEffect, no loading flag, no fetch to your own backend. Less code runs in the browser, the HTML arrives complete (good for search engines), and there is less to maintain. Because this code touches the database directly, never import it into a client file; a server-only data module makes that boundary explicit.

Where Client Components still belong

You still need Client Components for anything interactive: event handlers, state, effects, and browser APIs such as localStorage. You mark such a file by putting the "use client" directive at the top:

// components/AddToCartButton.tsx
"use client";

The button below keeps a piece of local state and reacts to clicks, which is exactly the kind of work that has to happen in the browser:

import { useState } from "react";export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);  return (
    <button onClick={() => setAdded(true)}>
      {added ? "Added!" : "Add to Cart"}
    </button>
  );
}

Start with Server Components and switch only where interactivity requires it, pushing "use client" as far down the tree as possible: a server page embedding one small client button ships far less JavaScript than a page that is a Client Component from the top. For a deeper look at how the rendering model works under the hood, see the architecture behind zero-bundle rendering.

2. Server Actions replace API routes for mutations

Server Actions let you write a function that runs on the server and call it straight from a component. Next.js generates the HTTP endpoint, serializes the arguments and returns the result, so you no longer maintain a separate route just to accept a form submission.

The two-file pattern it retires

Previously a mutation needed a handler in the Pages Router api folder that read the request body, wrote to the database and answered with JSON:

// You needed an API route
// pages/api/create-post.ts
export default async function handler(req, res) {
  const { title, content } = req.body;
  await db.post.create({ data: { title, content } });
  res.status(200).json({ success: true });
}

The component then had to call that endpoint manually, serializing the payload itself:

// Then in your component:
const response = await fetch("/api/create-post", {
  method: "POST",
  body: JSON.stringify({ title, content }),
});

A validated action colocated with its form

With Server Actions, the page imports what it needs, including Zod for input validation:

// app/posts/new/page.tsx
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { z } from "zod";

The action is declared next to the form. The "use server" directive inside the function body marks it as server-only code; the form passes it to its action prop. The action validates FormData with safeParse, returns field errors if validation fails, and otherwise writes the post and redirects:

const schema = z.object({
  title: z.string().min(3, "Title must be at least 3 characters"),
  content: z.string().min(10, "Content is too short"),
});async function createPost(formData: FormData) {
  "use server";  const parsed = schema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }  await db.post.create({ data: parsed.data });
  redirect("/posts");
}export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" required />
      <textarea name="content" placeholder="Write something..." required />
      <button type="submit">Publish</button>
    </form>
  );
}

Validation is essential because a Server Action is a public endpoint anyone can call with arbitrary data. For the same reason, authorization checks belong inside the action itself; why Server Actions need authorization inside every function body covers that in detail. Also note that nothing here reads the returned error object, so failed validation shows nothing. The next pattern fixes that.

Showing errors and pending state with useActionState

When the form must display validation messages or disable the button while a request is in flight, move the form into a Client Component and wrap the action with React's useActionState hook:

"use client";

The hook returns the latest state produced by the action, a wrapped formAction to pass to the form, and an isPending flag. The component renders the first error per field and swaps the button label while the submission runs:

import { useActionState } from "react";
import { createPost } from "./actions";export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null);  return (
    <form action={formAction}>
      <input name="title" placeholder="Post title" />
      {state?.error?.title && (
        <p className="text-red-500">{state.error.title[0]}</p>
      )}
      <textarea name="content" placeholder="Write something..." />
      {state?.error?.content && (
        <p className="text-red-500">{state.error.content[0]}</p>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? "Publishing..." : "Publish"}
      </button>
    </form>
  );
}

A detail that often causes confusion: when an action is used through useActionState, React calls it with the previous state as the first argument and the FormData as the second. The createPost shown earlier takes only formData, so a version exported from ./actions for this hook needs the signature (prevState, formData). The action also has to live in a separate file with "use server" at the top, because a Client Component cannot define server functions inline.

3. Turbopack shortens the development feedback loop

For a long time webpack set the pace of local development. Fast Refresh was pleasant once the server was running, but cold starts on large applications could take 30 to 60 seconds. Turbopack is a Rust-based bundler from Vercel designed to remove that bottleneck.

Turbopack reached a 100% pass rate on all 8,298 Next.js integration tests, and teams report development cold starts under 3 seconds on projects that used to need more than a minute. Its maturity label has changed quickly between releases, so check the current docs for its status in your version, particularly for production builds.

Turning it on

Opting in for development is a single flag on the dev script:

// package.json
{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build"
  }
}

No extra configuration is needed. Turbopack computes incrementally, rebuilding only what changed, so larger projects gain the most. If you rely on custom webpack loaders or plugins, confirm Turbopack equivalents first. Our bundler comparison goes through those trade-offs.

4. Partial Prerendering combines a static shell with streamed data

Partial Prerendering (PPR) serves a prebuilt static HTML shell immediately and streams the dynamic parts of the same page into it, all within one response.

On a product page, the layout, navigation and description are the same for everyone; the personalized price, cart and stock count are not. PPR sends the shell at once and fills in the per-request pieces as they resolve.

The page imports one static component and two dynamic ones:

// app/product/[id]/page.tsx
import { Suspense } from "react";
import { ProductDetails } from "./ProductDetails"; // static
import { PersonalizedPrice } from "./PersonalizedPrice"; // dynamic
import { StockStatus } from "./StockStatus"; // dynamic

The boundary between static and dynamic is drawn with Suspense. Everything outside a boundary can be prerendered; each Suspense fallback becomes a placeholder in the shell that is replaced when its child finishes rendering on the server:

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* This renders statically - instant */}
      <ProductDetails id={params.id} />      {/* These stream in dynamically */}
      <Suspense fallback={<div>Loading price...</div>}>
        <PersonalizedPrice productId={params.id} />
      </Suspense>      <Suspense fallback={<div>Checking stock...</div>}>
        <StockStatus productId={params.id} />
      </Suspense>
    </div>
  );
}

Note that params is typed as a plain object here. In recent Next.js versions params is passed as a Promise and must be awaited, so adjust that signature to the version you target.

PPR was introduced behind an experimental flag in the Next.js config, starting with the type import:

// next.config.ts
import type { NextConfig } from "next";

and then the flag itself:

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
};export default nextConfig;

At the time of writing this flag has been reorganized in newer releases (PPR behavior is tied to the Cache Components setting in Next.js 16), so treat the snippet as illustrative and confirm the current option name. Either way, the page feels static because the shell is cached, while its data stays live. We cover the mechanics in more depth in partial pre-rendering and concurrent rendering explained.

5. The use cache directive makes caching explicit

Next.js 13 and 14 cached aggressively by default, and many teams discovered stale pages in production without any obvious cause. Next.js 16 moves toward opt-in caching with Cache Components and the use cache directive: you mark what should be cached instead of guessing what already is.

Placing the directive at the top of an async component caches its rendered output:

// A component that caches its output for 1 hour
async function PopularArticles() {
  "use cache";

The rest of the component fetches and renders as usual:

  const articles = await fetch("https://api.example.com/popular-articles").then(
    (r) => r.json()
  );  return (
    <ul>
      {articles.map((article: { id: string; title: string }) => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  );
}

The comment mentions one hour, but the directive alone does not set a duration; the lifetime comes from a cache profile, applied with cacheLife, and falls back to a default profile otherwise. Custom profiles are declared in the config:

// next.config.ts
const nextConfig = {
  experimental: {
    cacheLife: {
      "stale-for-a-day": {
        stale: 60 * 60, // 1 hour
        revalidate: 60 * 60 * 24, // 1 day
        expire: 60 * 60 * 24 * 7, // 1 week
      },
    },
  },
};

The three values answer different questions. stale is how long a client may use its copy without checking the server, revalidate is how often the server refreshes the entry in the background, and expire is the point after which the entry is discarded and the next request must wait for fresh data. Whether cacheLife sits under experimental depends on your version. For tag-based invalidation, which you will need once data changes on writes, see our guide to use cache and tag-based revalidation.

6. Streaming AI features with the AI SDK

The Vercel AI SDK integrates with Route Handlers and React hooks, so streaming chat, AI-assisted search and generated UI become ordinary application code.

On the server, a Route Handler imports streamText and a model provider:

// app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";

The POST handler reads the conversation from the request body, starts a streaming completion and returns it as a streamed response (the closing brace of the function is cut off in the snippet):

export async function POST(req: Request) {
  const { messages } = await req.json();  const result = streamText({
    model: openai("gpt-4o"),
    messages,
  });  return result.toDataStreamResponse();

On the client, the chat page is a Client Component because it holds input state:

// app/chat/page.tsx
"use client";

The useChat hook manages the message list, the input value and submission, and re-renders as tokens arrive:

import { useChat } from "ai/react";export default function ChatPage() {
  const { messages, input, handleInputChange, handleSubmit } = useChat();  return (
    <div>
      <div>
        {messages.map((m) => (
          <div key={m.id}>
            <strong>{m.role}:</strong> {m.content}
          </div>
        ))}
      </div>
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} placeholder="Ask anything..." />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

That is about 30 lines for a streaming chat, with a Route Handler on the backend and useChat on the frontend. Be aware that the AI SDK's API moves quickly: in newer major versions the hook is imported from @ai-sdk/react, input state is managed by you, and the response helper has a different name. Pin your versions and check the SDK documentation before copying this. For agent-style interfaces with tools and multiple steps, see building multi-step AI agent UIs with Next.js and the AI SDK.

7. App Router patterns for complex layouts

The App Router, introduced in Next.js 13, is now the standard way to build Next.js apps. Two of its features replace what used to need custom state management.

Parallel Routes for independent dashboard panels

Parallel Routes render several pages inside one layout at the same time. Each folder prefixed with @ defines a named slot:

app/
  dashboard/
    @analytics/
      page.tsx
    @recent/
      page.tsx
    layout.tsx
    page.tsx

The layout receives each slot as a prop alongside children and places them in the grid:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  recent,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  recent: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="col-span-2">{children}</div>
      <aside>
        {analytics}
        {recent}
      </aside>
    </div>
  );
}

Because each slot is its own route segment, it loads its own data and can have its own loading and error states. A slow analytics query does not hold back the recent-activity panel. One pitfall: when you navigate to a sub-path that a slot does not define, Next.js needs a default.tsx in that slot to know what to render on a hard reload.

Intercepting Routes for modals with real URLs

A common interface pattern opens an item in a modal while the URL changes to that item, so it can be shared. Intercepting Routes handle this with folder conventions:

app/
  photos/
    [id]/
      page.tsx      // Full page view at /photos/123
    (..)[id]/
      page.tsx      // Intercepted modal view
  page.tsx

When a user clicks from the grid, the intercepting folder catches the client-side navigation and renders the modal version. When someone opens the URL directly or refreshes, the regular full page renders instead, with no manual history tricks. The (.), (..) and (...) markers are relative to route segments, not file-system folders, and the modal is usually rendered through a parallel @modal slot, so check the routing docs for the exact layout your structure needs.

8. SEO primitives: metadata and images

The Metadata API turns SEO into regular code that lives next to the page it describes. After importing the Metadata type:

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";

a page exports generateMetadata, which loads the post and returns the title, description, Open Graph data and Twitter card:

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await getPost(params.slug);  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage }],
      type: "article",
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

Social previews, canonical URLs and structured data come from the same data the page renders. If getPost is also called by the page component, deduplicate the request (for example with React's cache) so the database is not queried twice.

For images, next/image should be your default:

import Image from "next/image";

It lazy-loads by default, serves correctly sized variants and converts to WebP or AVIF where the browser supports them. Explicit width and height also reserve space and prevent layout shift:

export function ProductCard({ product }: { product: Product }) {
  return (
    <div>
      <Image
        src={product.imageUrl}
        alt={product.name}
        width={400}
        height={300}
        priority={false} // set true for above-the-fold images
      />
      <h2>{product.name}</h2>
    </div>
  );
}

Set priority to true only for the image that is visible above the fold, typically the hero or main product image, so the browser fetches it early.

A sensible learning order

Each step here builds on the previous one:

  1. App Router file conventions: layout.tsx, page.tsx, loading.tsx and error.tsx.
  2. Server Components and where the client boundary sits.
  3. Server Actions with Zod validation for every mutation.
  4. Suspense and streaming for declarative loading states.
  5. Partial Prerendering for the static-plus-dynamic model.
  6. Turbopack in development for faster iteration.

Key takeaways

  • Treat the server as the default runtime and push "use client" down to the smallest interactive leaves.
  • Server Actions are endpoints: validate input and check permissions inside each one.
  • Draw your static and dynamic boundaries with Suspense; PPR and caching both build on them.
  • Prefer explicit caching with use cache and named cacheLife profiles over relying on defaults.
  • Many of these APIs changed between recent releases, so confirm flags and signatures against the version you actually run.

Whether you start fresh or migrate a large app to the App Router, adopting these pieces one at a time is a low-risk path.