DEV Community

Cover image for Modern Next.js Essentials: Building Scalable Full-Stack Applications
Preyum Kumar
Preyum Kumar

Posted on • Edited on

Modern Next.js Essentials: Building Scalable Full-Stack Applications

In an AI-driven world, we should keep our basics sharp at all times. This is the final post in my next.js learning series where we'll go over the fundamentals of Next.js.

Index

  1. Fundamentals
  2. The Legacy Page Router
  3. The App Router
  4. Client Components and 'use client'
  5. Advanced Routing and Navigation
  6. Layouts and UI Shells
  7. UI and Styling with shadcn/ui
  8. Theming with next-themes
  9. Schema Validation with Zod
  10. Form Validation with React Hook Form and Zod
  11. Server-Side Logic: Route Handlers vs. Server Actions
  12. The 'Proxy' Concept (formerly Middleware)
  13. Real-time Data and Communication
  14. Authentication with Better Auth
  15. Toasts with Sonner
  16. Handling UI States: Loading, Errors, and Not Found
  17. Built-in Optimizations and SEO
  18. Data Fetching, Caching, and Revalidation
  19. Identifying Static vs. Dynamic Routes

Fundamentals

Next.js is a powerful React framework used to build full-stack web applications. It provides several out-of-the-box features that simplify the development process and improve performance.

  • Server-Side Rendering (SSR): Next.js pre-renders pages on the server for each request. This is great for Search Engine Optimization (SEO) as crawlers can see the full content of the page immediately.
  • Production Ready: It includes automatic code splitting, optimized image loading, and built-in CSS (Cascading Style Sheets) / SASS (Syntactically Awesome Style Sheets) support.
  • Easy Setup: You can start a new project instantly using:

    npx create-next-app@latest
    

Next.js and TypeScript (Generics)

If you see syntax like GetServerSideProps<PageProps> or useState<string>() in a Next.js project, those are Generics.

  • Origin: Generics are a feature of TypeScript, not JavaScript or React.
  • Purpose: They allow you to pass a "type" as a variable to another type. This is what makes Next.js so powerful when combined with TypeScript; it ensures that the data you fetch on the server matches the data your component expects on the client.
  • Example:

    // The <User> part is a Generic. 
    // It tells the function exactly what shape the response will have.
    const user = await fetchUser<User>(id); 
    

    For a deep dive into how they work in React and Next.js, see: Generics in React and Next.js.

Rendering Patterns: SPA, SSR, and Next.js Hybrid

Understanding the difference between Single Page Applications (SPA) and Server-Side Rendering (SSR) is key to mastering Next.js. Next.js is unique because it allows you to use both individually or combine them seamlessly.

1. The SPA "Feel" (Client-Side Navigation)

In a pure React SPA, you have smooth transitions because the browser never reloads. Next.js keeps this "feel" by using the <Link> component. When you click a link, only the necessary data is fetched, and the URL changes without a full browser refresh.

2. The SSR "Foundation" (Server Components)

Unlike a pure SPA (which sends an empty HTML file), Next.js pre-renders every page on the server.

  • Initial Load: The server sends a fully populated HTML file (Great for SEO and "First Paint").
  • Default Behavior: This is the default for Server Components.

3. The Next.js Hybrid: The Best of Both Worlds

Next.js is unique because it combines these patterns into a "Hybrid SPA":

  1. Server side: On the first request, the page is SSR-ed for speed and SEO.
  2. Browser side: Once the page loads, the Next.js router takes over. Every subsequent navigation via <Link> is handled on the client side (like an SPA), meaning no full page reloads, even for Server Components!

Detailed Example: Mixing Both

Imagine a Product Page. The product details are fetched via SSR (Server Component), but the "Add to Cart" button is interactive (Client Component).

// app/products/[id]/page.tsx (Server Component - SSR)
import AddToCartButton from './AddToCartButton';

export default async function ProductPage({ params }: { params: { id: string } }) {
  // This fetch happens on the server during the initial load
  const product = await fetchProduct(params.id);

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>

      {/* This component hydrates and becomes interactive in the browser */}
      <AddToCartButton productId={product.id} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why this is powerful:

  • Search Engines see your content immediately because of SSR.
  • Users get a near-instant first view.
  • Navigating between products feels like a mobile app because Next.js only swaps the data, not the whole page.

The Legacy Page Router

The Page Router was the original way to handle routing in Next.js. In this system, every file created inside the pages directory automatically became a route.

  • pages/index.js -> /
  • pages/about.js -> /about

While simple, it lacked advanced features like native nested layouts and React Server Components, leading to the introduction of the App Router.

The App Router

Introduced in Next.js 13, the App Router is the modern standard. It is built on React Server Components and uses a file-system-based router where folders define routes.

  • Folders as Routes: A folder inside the app directory represents a URL segment.
  • page.tsx: To make a route publicly accessible, you must include a page.tsx file inside that folder.
  • Default Export: Every page.tsx must have an export default function to render the UI.

Example:

// app/contact/page.tsx
export default function ContactPage() {
  return (
    <main>
      <h1>Contact Us</h1>
      <p>Feel free to reach out via email.</p>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Client Components and 'use client'

In the App Router, all components are Server Components by default. To enable interactivity, you must use the 'use client' directive.

What happens when you add 'use client'?

When you mark a file with 'use client', several things happen behind the scenes:

  1. Client-Side Rendering: The component (and all its imported dependencies) is added to the Client-Side JavaScript Bundle.
  2. Hydration: The component is still pre-rendered into static HTML on the server for SEO, but it then "hydrates" in the browser to become interactive.
  3. Client Boundary: It establishes a "Client Boundary." Any component imported into a Client Component automatically becomes a Client Component as well.
  4. Enables Interactivity: You gain access to event handlers (onClick), React Hooks (useState, useEffect), and Browser APIs (window, localStorage).
  • When to Use It: You need 'use client' whenever your component requires:
    • Interactivity: Event listeners like onClick, onChange, or onSubmit.
    • React Hooks: State (useState), effects (useEffect), or context (useContext).
    • Browser APIs: Access to window, document, or localStorage.
  • Placement: The 'use client' directive must be placed at the very top of the file, before any imports.

The Client Boundary Effect (Implicit Client Components)

A common misconception is that every interactive component needs its own 'use client' tag. In reality, the directive establishes a Client Boundary that "infects" the entire dependency tree below it.

  • Implicit Conversion: If a component is imported into a file that has the 'use client' directive, it is implicitly converted into a Client Component. It will be included in the client-side JavaScript bundle and rendered on the client.
  • No Directive Needed: Child components imported into a Client Component do not need their own 'use client' tag to use hooks or interactivity, as they are already executing within the client runtime.
  • Module Dependency: Because the parent needs to render the child in the browser, the build tool must include the child's code in the client bundle.

Example: Parent vs. Child

// app/components/ParentComponent.tsx
'use client'; // This establishes the Client Boundary

import { useState } from 'react';
import ChildComponent from './ChildComponent';

export default function ParentComponent() {
  const [count, setCount] = useState(0);

  return (
    <div className="p-4 border">
      <h2>Parent (Client Component)</h2>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>

      {/* The Child is imported and rendered here */}
      <ChildComponent />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
// app/components/ChildComponent.tsx
// Note: NO 'use client' directive here!

export default function ChildComponent() {
  // This will still render on the client because it's imported into ParentComponent.
  console.log("I am rendering in the browser!"); 

  return (
    <div className="mt-4 p-2 bg-gray-100">
      <h3>Child Component</h3>
      <p>I don't have 'use client', but I'm part of the client bundle!</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why this matters: To keep your client bundle small, you should move 'use client' as far down the component tree as possible (to the "leaf" components) to avoid accidentally pulling static components into the client-side JavaScript.

The Payload Secret: JS vs. Data

A crucial performance optimization in Next.js is how it delivers code to the browser. It uses two different "packages":

1. The RSC (React Server Components) Payload (The "Data")

This is a compact, text-based representation of your UI.

  • Used by: Both Server and Client Components.
  • Contents: The rendered HTML-like structure, props, and "placeholders" for client components.
  • The Benefit: It tells React what to show on the screen without sending any JavaScript logic for Server Components.

2. The JS Bundle (The "Code")

This is the actual executable JavaScript file.

  • Used by: Client Components ONLY.
  • Contents: The logic (useState, useEffect), event handlers, and imported libraries.
  • The Difference: Server Components NEVER send their JavaScript to the browser. Their JS stays on the server to fetch data and render, then it is "stripped away." Only Client Components pay the "JavaScript tax" of being downloaded and executed by the user's browser.

Next.js 16 Pro Tip: Version 16 introduces Layout Deduplication. When you navigate between pages that share a layout, Next.js only fetches the RSC payload for the new page content, not the shared layout, making navigations up to 50% lighter.

Next.js provides a powerful file-system-based router. Beyond basic page creation, it supports advanced routing patterns to handle complex application structures.

Dynamic Routing

Next.js allows you to create routes that match multiple URL patterns using dynamic segments.

Note for Next.js 15+: The params and searchParams props are now Promises and must be awaited (or handled with React's use() hook) before access.

  • [id] Paths: By wrapping a folder name in square brackets, you create a dynamic route (e.g., app/posts/[id]/page.tsx).
  • Mixed Combinations: You can nest dynamic segments to create complex paths like /categories/[category]/[id].

Example (Next.js 15+):

// app/blog/[blogId]/page.tsx
interface BlogIdPageProps {
  params: Promise<{ blogId: string }>;
}

export default async function BlogIdPage({ params }: BlogIdPageProps) {
  const { blogId } = await params;

  return (
    <main>
      <h1>Hello from the blog id page</h1>
      <p>Viewing blog post ID: {blogId}</p>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Route Groups

Route Groups allow you to organize your files and group related routes without affecting the URL structure. You can create a route group by wrapping a folder name in parentheses: (folderName).

  • URL Impact: The folder name in parentheses is ignored by the router. For example, app/(auth)/login/page.tsx is accessible at /login, not /(auth)/login.
  • Shared Layouts: You can add a layout.tsx inside a route group to apply a specific UI (like an auth header or sidebar) only to the routes within that group.
  • Organization: They are excellent for organizing large projects by feature, team, or intent (e.g., (admin), (marketing)) without cluttering the URL.

Sub-navigation is used to create persistent UI elements (like sidebars or tabs) that only exist within a specific part of your application. This is achieved using Nested Layouts.

  • How it works: A layout in a sub-folder (e.g., app/dashboard/layout.tsx) wraps all pages and sub-folders within that directory.
  • Benefits: The sub-navigation component remains mounted during navigation between sub-pages, preserving state and preventing un-necessary re-renders.

Example:

// app/dashboard/layout.tsx
import Link from 'next/link';

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex">
      <nav className="w-64 border-r">
        {/* Persistent Sub-Navigation */}
        <Link href="/dashboard">Overview</Link>
        <Link href="/dashboard/settings">Settings</Link>
      </nav>
      <main className="flex-1 p-4">
        {children}
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Parallel Routes

Parallel Routes allow you to simultaneously or conditionally render one or more pages within the same layout. They are created using named slots, e.g., @folder.

  • Usage: Useful for highly dynamic sections like dashboards or social media feeds where you want multiple independent views (e.g., a team view and an analytics view on the same dashboard).
  • Independent Error/Loading States: Each parallel route can have its own loading.tsx and error.tsx, meaning one slow section won't block the rest of the page.
  • Slot Props: Slots are passed as props to the shared parent layout.

Example File Structure:

app/dashboard/
β”œβ”€β”€ layout.tsx
β”œβ”€β”€ @analytics/
β”‚   └── page.tsx
└── @team/
    └── page.tsx
Enter fullscreen mode Exit fullscreen mode

Handling Unmatched Routes (The default.tsx Fix)

A common issue with Parallel Routes occurs when you navigate to a sub-page that only exists in one of your slots.

  • The Problem (404 on Refresh): If you navigate from /dashboard to /dashboard/settings, and only the @analytics slot has a settings/page.tsx file, Next.js will keep the "previous" content for the @team slot. However, if you refresh the browser on /dashboard/settings, Next.js doesn't know what to show in the @team slot and will return a 404 error.
  • The Fix: You must provide a default.tsx file in each slot directory. This file acts as a fallback UI when the current URL does not match any page within that specific slot.

Example Fix:

app/dashboard/
β”œβ”€β”€ @analytics/
β”‚   β”œβ”€β”€ page.tsx
β”‚   └── settings/page.tsx
└── @team/
    β”œβ”€β”€ page.tsx
    └── default.tsx  <-- This prevents 404 on /dashboard/settings refresh
Enter fullscreen mode Exit fullscreen mode

What to put in default.tsx?
To avoid the "empty box" issue on refresh, the common fix is to make default.tsx an exact copy of your main page.tsx for that slot. This ensures the UI remains identical whether you arrive via a link or a refresh.

// app/dashboard/@team/default.tsx
// This code is a copy of @team/page.tsx
export default function Default() {
  return <div>Team Dashboard Content</div>;
}
Enter fullscreen mode Exit fullscreen mode
  • Soft Navigation (Clicking Links): Next.js will preserve the slot's previously active state, even if it doesn't match the new URL.
  • Hard Navigation (Browser Refresh): Next.js cannot preserve state. It must find a default.tsx file for every unmatched slot, or it will render a 404. Making default.tsx a copy of page.tsx ensures the user sees the same content regardless of how they arrived at the URL.

Example Layout Usage:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children, // The main page.tsx content
  analytics, // Content from @analytics
  team,      // Content from @team
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <div>
      {children}
      <div className="flex gap-4">
        {analytics}
        {team}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Intercepting Routes (The "Modal" Pattern)

Intercepting routes allow you to load a new route from another part of your application within the current layout. This is best understood through the Instagram Analogy:

  1. The Interception (Soft Navigation): You are scrolling through your feed (/feed). You click a photo. Instead of taking you to a whole new page, the photo pops up in a Modal (overlay). The URL changes to /photo/123, but you are still looking at your feed in the background.
  2. The Standalone (Hard Navigation): If you copy that URL (/photo/123) and send it to a friend, or if you refresh the browser, you don't want a modal over an empty feed. You want a full-page view of just the photo.

1. Why use them?

  • Context Preservation: The user doesn't lose their scroll position or "place" in the app while viewing a sub-item.
  • Shareable URLs: Every item has its own URL that works as a standalone page.

2. The Naming Conventions

The symbols refer to the relative path from the current folder to the folder you want to intercept:

  • (.): Intercepts a route at the same level.
  • (..): Intercepts a route one level up.
  • (..)(..): Intercepts a route two levels up.
  • (...): Intercepts a route from the root app directory.

To make this work, you usually combine Intercepting Routes with Parallel Routes (to define where the modal should appear).

File Structure:

app/
β”œβ”€β”€ layout.tsx         <-- Contains {children} and {modal} slots
β”œβ”€β”€ @modal/            <-- Parallel Route slot
β”‚   └── (.)photo/      <-- Intercepts '/photo' from the root app level
β”‚       └── [id]/
β”‚           └── page.tsx  <-- UI for the MODAL version
└── photo/
    └── [id]/
        └── page.tsx      <-- UI for the FULL PAGE version
Enter fullscreen mode Exit fullscreen mode

How it works in practice:

  • When a user clicks <Link href="/photo/5"> from anywhere in the app:
    • Next.js sees the (.)photo folder.
    • Instead of navigating away, it renders app/@modal/(.)photo/[id]/page.tsx inside the {modal} slot of your layout.
  • When a user refreshes the page at /photo/5:
    • Next.js ignores the interception.
    • It renders the standard app/photo/[id]/page.tsx as a full page.

Layouts and UI Shells

Layouts allow you to share UI across multiple pages, such as navigation bars or footers.

  • layout.tsx: A layout file wraps the page.tsx (and any child segments).
  • Layered Shells: Layouts are loaded from top to bottom in layers. A root layout wraps the entire application, while nested layouts wrap specific sub-sections.
  • State Preservation: On navigation, layouts preserve their state and do not re-render, making the app feel faster.

Example:

// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <section className="dashboard-container">
      <aside>Sidebar Navigation</aside>
      <div className="content">
        {children} {/* This is where the page content is injected */}
      </div>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

UI and Styling with shadcn/ui

shadcn/ui is a collection of beautifully designed, accessible, and customizable components that you can copy and paste into your apps. Unlike a component library, it is a collection of reusable components that you own and can fully customize.

  • Initialization: Start by initializing the CLI in your Next.js project:

    npx shadcn@latest init
    

    This command is interactive and will ask you several configuration questions (style, base color, CSS variables, etc.). Upon completion, it creates a components.json file in your root directory to track your preferences.

  • Adding Components: Add only the components you need (e.g., Button, Card, Dialog):

    npx shadcn@latest add button
    
  • Usage: Components are added directly to your @/components/ui folder, giving you full control over the code.

Example:

import { Button } from "@/components/ui/button"

export default function Home() {
  return (
    <div>
      <Button variant="outline">Click Me</Button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Theming with next-themes

When using shadcn/ui, it's common to use next-themes to handle light, dark, and system theme switching.

  • ThemeProvider: This component provides a context for the current theme across your entire application.
  • Why 'use client'?: The ThemeProvider wrapper must be a Client Component because it manages state (using React Context and hooks like useState or useEffect) and interacts with browser APIs like localStorage to persist the user's theme preference.
  • suppressHydrationWarning: You must add suppressHydrationWarning to your root <html> tag in layout.tsx.
    • Reason: On the server, Next.js doesn't know the user's client-side theme preference (e.g., from localStorage). When next-themes updates the class or data-theme on the <html> tag on the client to match the saved preference, it causes a mismatch with the server-rendered HTML. Adding this flag tells React to ignore that specific mismatch during hydration.

Example (layout.tsx):

// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider"

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

Schema Validation with Zod

Zod is a TypeScript-first schema declaration and validation library. It is widely used in Next.js projects to bridge the gap between runtime data validation and compile-time type safety.

  • Runtime Validation: Use Zod to define a schema for your data (e.g., API responses, form data, environment variables). This ensures that the data your app receives actually matches what you expect.
  • Type Inference with z.infer: One of Zod's most powerful features is Type Inference. Instead of manually defining an interface or type that mirrors your validation logic, Zod can automatically generate a TypeScript type from your schema.
  • Single Source of Truth: This eliminates redundancy and ensures that your static type definitions are always perfectly synchronized with your runtime validation rules.

Example:

import { z } from "zod";

// 1. Define the schema (Runtime validation)
const UserSchema = z.object({
  id: z.string().uuid(),
  username: z.string().min(3, "Username must be at least 3 characters"),
  email: z.string().email("Invalid email address"),
  age: z.number().int().positive().optional(),
});

// 2. Infer the type (Compile-time type)
// This 'User' type is automatically kept in sync with the schema above.
type User = z.infer<typeof UserSchema>;

// 3. Use the inferred type in your code
const processUser = (user: User) => {
  console.log(`Processing user: ${user.username}`);
};

// 4. Validate data at runtime (e.g., from an API call or form)
const result = UserSchema.safeParse({
  id: "550e8400-e29b-41d4-a716-446655440000",
  username: "johndoe",
  email: "john@example.com",
});

if (result.success) {
  // result.data is automatically typed as 'User'
  processUser(result.data);
} else {
  // Detailed error information if validation fails
  console.error(result.error.format());
}
Enter fullscreen mode Exit fullscreen mode

Form Validation with React Hook Form and Zod

React Hook Form (RHF) is the standard for managing form state in React. When combined with Zod via the @hookform/resolvers package, it provides a powerful, type-safe validation layer.

  • Installation:

    npm install react-hook-form zod @hookform/resolvers
    
  • The Validation Bridge: @hookform/resolvers is a library that allows you to use external validation libraries (like Zod, Yup, Joi, or Superstruct) with React Hook Form. By default, RHF uses HTML-standard validation (like required, minLength), but the resolvers package allows you to use a single, powerful schema for all your validation logic.

  • zodResolver: This specific resolver function takes your Zod schema and converts it into a format that React Hook Form understands. It handles the mapping of Zod errors to RHF's errors object automatically.

  • Benefits:

    • Type Safety: Use z.infer to ensure your form data types are always in sync with your validation rules.
    • Clean Code: Keeps validation logic out of your JSX and centralized in a reusable schema.
    • Automatic Errors: RHF automatically populates the errors object based on your Zod schema's messages.

Example:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

// 1. Define the schema
const schema = z.object({
  username: z.string().min(3, "Username must be at least 3 characters"),
  email: z.string().email("Invalid email address"),
});

// 2. Infer the type from the schema
type FormData = z.infer<typeof schema>;

export default function MyForm() {
  // 3. Initialize the form with the Zod resolver
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data: FormData) => {
    console.log("Validated Data:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Username</label>
        <input {...register("username")} />
        {errors.username && <p className="error">{errors.username.message}</p>}
      </div>

      <div>
        <label>Email</label>
        <input {...register("email")} />
        {errors.email && <p className="error">{errors.email.message}</p>}
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Server-Side Logic: Route Handlers vs. Server Actions

Next.js provides two primary ways to run server-side code in response to client interactions: Route Handlers and Server Actions. Understanding when to use each is crucial for building efficient, secure, and maintainable applications.

1. Route Handlers (route.ts)

Route Handlers allow you to create custom request handlers for a given route using the Web Request and Response APIs. They are the App Router equivalent of API Routes from the Page Router.

  • When to Use:
    • External APIs: When you need to provide an API endpoint for external clients (mobile apps, 3rd party services).
    • Webhooks: Handling incoming requests from services like Stripe, GitHub, or Clerk.
    • Custom Responses: When you need to return non-HTML content like JSON, XML, or binary data (PDFs, images).
    • Specific HTTP Methods: When you need full control over GET, POST, PUT, DELETE, etc.

Example: A Simple JSON API

// app/api/hello/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  return NextResponse.json({ message: 'Hello from the Route Handler!' });
}

export async function POST(request: Request) {
  const data = await request.json();
  return NextResponse.json({ received: data }, { status: 201 });
}
Enter fullscreen mode Exit fullscreen mode

2. Server Actions

Server Actions are asynchronous functions that are executed on the server. They can be defined in Server Components or called from Client Components. They are the modern standard for handling data mutations (POST, PUT, DELETE) in Next.js.

  • When to Use:
    • Form Submissions: Handling user input from forms.
    • Data Mutations: Creating, updating, or deleting records in a database.
    • Seamless Integration: When you want to trigger server-side logic from a button click or form submit without managing a separate API route.
    • Progressive Enhancement: They work even if JavaScript is disabled in the browser (when used with <form action={...}>).

Example: Handling a Form Submission

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title');

  // 1. Validate data (e.g., using Zod)
  // 2. Save to database (e.g., Prisma, Drizzle)
  console.log(`Creating post: ${title}`);

  // 3. Revalidate the cache to show new data
  revalidatePath('/blog');
}
Enter fullscreen mode Exit fullscreen mode

Route Handlers vs. Server Actions: A Quick Comparison

Feature Route Handlers (route.ts) Server Actions
Primary Use REST APIs, Webhooks, External clients Data mutations, Form submissions
Call Method HTTP Request (fetch('/api/...')) Direct function call (await action())
HTTP Methods Full control (GET, POST, etc.) Always uses POST under the hood
Response Type Any (JSON, XML, PDF, etc.) Data or Action result
Progressive Enhancement No Yes (works without JS)
Caching Can be cached (GET requests) Never cached

The 'Proxy' Concept (formerly Middleware)

In Next.js 16, the file previously known as middleware.ts has been renamed to proxy.ts. This change more accurately reflects its role as a network boundary and request gateway.

Why the Change to 'Proxy'?

  • Clarified Role: It acts as a "Reverse Proxy" for all incoming traffic before it reaches your application logic.
  • Node.js Runtime: Unlike the old middleware, proxy.ts runs on the Node.js runtime by default, giving you access to the full suite of Node.js APIs.
  • Performance: It remains optimized for the edge but provides a more predictable environment for complex logic.

How it Works

The proxy.ts file must be located at the root of your project. It exports a function named proxy that intercepts every incoming request.

Key Capabilities:

  1. Rewriting: Changing the internal destination (the actual proxying) using NextResponse.rewrite().
  2. Redirecting: Sending users to a different URL.
  3. Header/Cookie Injection: Modifying requests before they reach your pages or APIs.

Example Implementation (proxy.ts)

// proxy.ts (at the root of your project)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

// The function MUST be named 'proxy' in Next.js 16+
export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const token = request.cookies.get('session-token');

  // 1. Authentication Boundary
  if (pathname.startsWith('/dashboard') && !token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // 2. Reverse Proxying
  if (pathname.startsWith('/old-api')) {
    // Internally routes to a legacy server while keeping the URL the same
    const legacyUrl = new URL(pathname.replace('/old-api', ''), 'https://api.legacy-system.com');
    return NextResponse.rewrite(legacyUrl);
  }

  return NextResponse.next();
}

// Matcher config to limit where the proxy runs
export const config = {
  matcher: ['/dashboard/:path*', '/old-api/:path*'],
};
Enter fullscreen mode Exit fullscreen mode

Migration Note

If you are moving from an older version of Next.js:

  1. Rename middleware.ts to proxy.ts.
  2. Rename the exported function middleware to proxy.
  3. middleware.ts is still supported for backwards compatibility but is considered deprecated.

Security Warning: The 'Two-Layer' Auth Pattern

A common mistake is relying only on the proxy.ts (middleware) for authentication. While it is great for User Experience, it is insecure if used as your only line of defense.

1. Why Proxy-only Checks are Insecure

  • Surface Level: The proxy usually only checks if a cookie exists. It rarely performs a deep database check (to see if the user is banned or the session is revoked) because doing so would slow down every single request to your site.
  • Bypass Risks: Depending on your deployment configuration (e.g., using certain CDNs or split-routing), it is sometimes possible to craft requests that bypass the proxy layer and hit your internal APIs directly.
  • Stale Data: If a user is deleted from your database, their browser might still have a "valid-looking" cookie. The proxy will let them in because it doesn't want to hit the DB on every click.

2. The Solution: Two-Layer Validation

Professional Next.js applications use a Defense in Depth strategy.

  • Layer 1: The Proxy (UX Layer)

    • Goal: Speed.
    • Action: Do a quick check for a session cookie. If it's missing, redirect to /login.
    • Result: The user doesn't see a "flash" of protected content before being redirected.
  • Layer 2: The Server Component/Action (Security Layer)

    • Goal: Absolute Security.
    • Action: Perform a rigorous database check. Verify permissions, roles, and session validity.
    • Result: Even if someone bypasses the proxy, they cannot fetch any sensitive data because the actual data-fetching function will block them.

Detailed Example: Two-Layer Check

// 1. THE UX LAYER (proxy.ts)
export function proxy(request: NextRequest) {
  const session = request.cookies.get('auth-session');
  if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}

// 2. THE SECURITY LAYER (app/dashboard/page.tsx)
export default async function DashboardPage() {
  // Authoritative check: Hit the DB to verify the user is actually active
  const user = await getAuthenticatedUser(); 

  if (!user) {
    // This handles cases where the session was revoked but the cookie still exists
    redirect('/login'); 
  }

  const data = await db.privateData.findMany({ where: { userId: user.id } });
  return <Dashboard data={data} />;
}
Enter fullscreen mode Exit fullscreen mode

Rule of Thumb: Use the Proxy for Redirects (UX), but use Server-side logic for Access Control (Security).

Real-time Data and Communication

Next.js is primarily built on a request-response model (HTTP). While it handles standard data fetching perfectly, it does not natively provide a persistent, bi-directional connection (like WebSockets) out of the boxβ€”especially in serverless environments (like Vercel). For real-time requirements, developers use specialized tools and frameworks.

1. Convex: The Reactive Full-Stack Backend

Convex is a modern, reactive backend-as-a-service. It combines a database, server functions, and real-time synchronization into a single tool.

  • Reactive Queries: Instead of manual fetching, you use useQuery. If the data in the database changes, the UI updates automatically across all connected clients.
  • Why it's powerful: It eliminates the need for complex WebSocket management or state synchronization libraries. It acts as your database and your real-time engine simultaneously.

2. Pusher: Managed WebSockets

Pusher is a hosted WebSocket service. It allows you to "trigger" events from your Next.js server (API routes/Server Actions) and "listen" for them on the client.

  • Infrastructure-less: You don't have to maintain a WebSocket server (like Socket.io), which is difficult to scale in serverless environments.
  • Bi-directional: It enables instant features like chat notifications, live dashboards, and "typing" indicators.

3. WebRTC and Signaling

For high-performance real-time applications involving Audio and Video calls, we use WebRTC (Web Real-Time Communication).

  • P2P Tunnels: WebRTC allows two browsers to connect directly to each other (Peer-to-Peer) to exchange large amounts of data with very low latency.
  • The Signaling Problem: Two peers cannot connect "out of the blue." They need to know each other's IP addresses and media capabilities. However, most devices are hidden behind firewalls.
  • The Solution (Signaling): Peers need a "handshake" server to exchange information (SDP and ICE Candidates). This is where Convex or Pusher come in:
    • Pusher can broadcast the "Offer" and "Answer" messages between peers.
    • Convex can store the signaling data in a reactive table that both peers are "watching."

Example: Real-time Messaging & Video App

In a professional app:

  1. Messaging: Use Convex to store messages. Since it's reactive, the chat UI updates instantly for both users.
  2. Calling: When a user clicks "Call," a signaling message is sent via Pusher or Convex to the other user.
  3. Video Stream: Once the "handshake" is complete via the signaling server, WebRTC takes over to create a direct P2P tunnel for the actual video and audio stream.

Authentication with Better Auth

Better Auth is a modern, type-safe authentication framework designed specifically for TypeScript and frameworks like Next.js. It provides a modular architecture where features (like MFA, Organizations, or Passkeys) are added via plugins.

  • Modular Architecture: You only add the features you need (e.g., social login, email/password, organizations) through a plugin-based system.
  • Database Agnostic: It works with any database using adapters (Drizzle, Prisma, etc.).
  • Type Safety: It provides end-to-end type safety for your user sessions and auth state.

Example Setup:

// lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db";

export const auth = betterAuth({
    database: drizzleAdapter(db, { provider: "pg" }),
    emailAndPassword: { enabled: true },
    socialProviders: {
        github: {
            clientId: process.env.GITHUB_CLIENT_ID!,
            clientSecret: process.env.GITHUB_CLIENT_SECRET!,
        },
    },
});

// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);
Enter fullscreen mode Exit fullscreen mode

Usage Example:

To use Better Auth in your application, you interact with the auth object on the server and the authClient on the client.

1. Client-Side Usage (Sign In & Session)

"use client";
import { createAuthClient } from "better-auth/react";

const authClient = createAuthClient();

export function LoginButton() {
  const signIn = async () => {
    // This action CREATES the token/session and sets the cookie
    await authClient.signIn.social({
      provider: "github",
      callbackURL: "/dashboard",
    });
  };

  return <button onClick={signIn}>Sign in with GitHub</button>;
}
Enter fullscreen mode Exit fullscreen mode

2. Server-Side Usage (Protecting Routes)

// app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  // Get the session on the server
  const session = await auth.api.getSession({
    headers: await headers(),
  });

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

  return <h1>Welcome back, {session.user.name}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Token Lifecycle: Creation vs. Validation

It is important to distinguish where tokens live:

  • Creation (Login): Tokens are created in the Auth Flow (Client-side signIn or Server-side Actions). The server generates a unique session ID, saves it to the database, and sends it to the browser as an encrypted cookie.
  • Validation (Middleware/Proxy): As seen in the The 'Proxy' Concept (formerly Middleware) section, the proxy checks for the existence of this cookie on every request to decide if it should allow the user to proceed.

Toasts with Sonner

Sonner is an opinionated, lightweight toast component for React, created by Emil Kowalski. It is the default recommendation for shadcn/ui due to its sleek design and stacking behavior.

  • Small & Fast: Extremely lightweight (less than 1kb gzipped).
  • Opinionated Design: Looks great out of the box with minimal configuration.
  • Stacking: Toasts elegantly stack on top of each other, allowing users to see multiple notifications at once.

Installation via shadcn:

npx shadcn@latest add sonner
Enter fullscreen mode Exit fullscreen mode

Usage Example:

// app/layout.tsx
import { Toaster } from "@/components/ui/sonner"

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        {/* 
          We place the Toaster in the Root Layout so it stays mounted 
          across all pages. This allows notifications to remain visible 
          even when the user navigates between different routes.
        */}
        <Toaster />
      </body>
    </html>
  )
}

// Any Client Component
"use client"
import { toast } from "sonner"

export function MyComponent() {
  return (
    <button onClick={() => toast("Operation Successful!")}>
      Show Toast
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Handling UI States: Loading, Errors, and Not Found

Next.js provides special file conventions to handle different states of your application's UI gracefully.

Streaming and Suspense (loading.tsx)

Streaming is a powerful data transfer technique that allows you to break down a page's HTML into smaller chunks and progressively send them from the server to the client. This is a game-changer for performance, especially on slow internet connections.

How Streaming Helps:

  1. Reduces Time to First Byte (TTFB): Instead of waiting for the entire page to be generated on the server, Next.js starts sending the UI as soon as it's ready.
  2. Partial Data Delivery: Users can see and interact with parts of the page (like a header or sidebar) while slower data-heavy components (like a product list or user profile) are still loading.
  3. Better Perceived Performance: The app feels "snappy" because the user isn't staring at a blank screen or a full-page spinner.

Method 1: Page-Level Streaming with loading.tsx

The simplest way to implement streaming is by creating a loading.tsx file in your route folder. This automatically wraps the page.tsx and any nested children in a React Suspense boundary.

  • Behavior: While the data in page.tsx is being fetched on the server, the UI defined in loading.tsx (e.g., a skeleton loader) is shown immediately.

Example:

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
      <div className="h-64 bg-gray-100 rounded"></div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Method 2: Component-Level Streaming with

For more granular control, you can use the <Suspense> component. This allows you to "stream" specific components independently, so one slow API call doesn't block the rest of the page.

  • Behavior: The "static" parts of the page render immediately, while the "dynamic" component shows a fallback UI until its data is ready.

Example:

import { Suspense } from 'react';
import { SlowProfileComponent, FastWeatherComponent } from './components';

export default function Page() {
  return (
    <main>
      <h1>My Dashboard</h1>

      {/* This renders immediately */}
      <FastWeatherComponent />

      {/* This streams in later without blocking the page */}
      <Suspense fallback={<p>Loading profile...</p>}>
        <SlowProfileComponent />
      </Suspense>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Comparison: loading.tsx vs. Granular Suspense

While both methods use React Suspense under the hood, they serve different purposes and offer different levels of control.

Feature loading.tsx (File-based) <Suspense> (Component-based)
Scope Entire Route: Wraps the whole page or layout. Granular: Wraps a specific component or section.
Ease of Use Zero Config: Just create the file and Next.js handles it. Manual: You decide exactly where to put the boundary.
User Experience Best for "full-page" transitions and skeleton shells. Best for keeping the "static" page visible while parts load.
TTFB Lowest: Browser gets the "loading" shell almost instantly. Low (Fast) for the static part, while dynamic parts stream later.
Control "All or nothing" for that specific route segment. High: Can have multiple boundaries with different fallbacks.

Rule of Thumb:

  • Use loading.tsx to show a main skeleton for a new page as it loads (UX: "I am going to a new section").
  • Use granular <Suspense> for secondary data within an already loaded page (UX: "I am on the page, and the dashboard widgets are popping in one by one").

Summary: Why it matters for Slow Internet

On a slow 3G or 4G connection, downloading a massive HTML file takes time. Streaming allows the browser to start parsing and rendering the "Shell" of your app immediately. By the time the slow data arrives, the user has already read the title and navigation, making the wait for the main content feel much shorter.

Modern Loading UI: Skeletons

While a simple "Loading..." text works, modern applications use Skeletons to provide a better user experience. Skeletons are placeholder versions of your UI that mimic the final layout (shape, size, and position) using grey blocks and subtle animations (like a "pulse" effect).

Why use Skeletons?
  1. Eliminate Layout Shift: By reserving the exact space a component will occupy, you prevent the page from "jumping" when the data finally arrives.
  2. Visual Continuity: It gives users a clear idea of what kind of content is loading (e.g., an image vs. a list of links).

Instead of one big spinner, you can stream individual sections of your page with their own custom skeletons.

// components/skeletons.tsx
export function BlogTileSkeleton() {
  return (
    <div className="border p-4 rounded-lg space-y-3 animate-pulse">
      <div className="h-48 bg-gray-200 rounded-md" /> {/* Image placeholder */}
      <div className="h-6 bg-gray-200 rounded w-3/4" /> {/* Title placeholder */}
      <div className="h-4 bg-gray-200 rounded w-full" /> {/* Description line 1 */}
      <div className="h-4 bg-gray-200 rounded w-2/3" /> {/* Description line 2 */}
    </div>
  );
}

export function SidebarTileSkeleton() {
  return (
    <div className="flex items-center space-x-4 p-2 animate-pulse">
      <div className="h-10 w-10 bg-gray-200 rounded-full" /> {/* Avatar/Icon */}
      <div className="flex-1 space-y-2">
        <div className="h-4 bg-gray-200 rounded w-3/4" />
        <div className="h-3 bg-gray-200 rounded w-1/2" />
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
Usage with Suspense
import { Suspense } from 'react';
import { BlogList, SidebarLinks } from './components';
import { BlogTileSkeleton, SidebarTileSkeleton } from './components/skeletons';

export default function BlogPage() {
  return (
    <div className="flex gap-8">
      {/* Main Content: Blog Tiles */}
      <section className="flex-1">
        <Suspense fallback={<div className="grid grid-cols-3 gap-4">
          {[...Array(6)].map((_, i) => <BlogTileSkeleton key={i} />)}
        </div>}>
          <BlogList />
        </Suspense>
      </section>

      {/* Sidebar Tiles */}
      <aside className="w-64">
        <Suspense fallback={<div className="space-y-4">
          {[...Array(5)].map((_, i) => <SidebarTileSkeleton key={i} />)}
        </div>}>
          <SidebarLinks />
        </Suspense>
      </aside>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Error Boundaries (error.tsx)

The error.tsx file convention allows you to gracefully handle runtime errors in nested routes. It automatically wraps a route segment and its nested children in a React Error Boundary.

  • Client Component Requirement: error.tsx must be a Client Component ('use client').
  • Isolation: Errors are isolated to the segment where they occur. The rest of the application (like a navigation bar or sidebar) remains functional.
  • Recovery: It provides a reset function to allow users to attempt to recover from the error (e.g., re-trying a failed data fetch).
  • Global Errors: For catching errors in the root layout or template, use global-error.tsx.

Example:

// app/dashboard/error.tsx
'use client';

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Optionally log the error to an error reporting service like Sentry
    console.error(error);
  }, [error]);

  return (
    <div className="flex flex-col items-center justify-center p-8 bg-red-50 text-red-900 rounded-md">
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button
        onClick={() => reset()}
        className="mt-4 px-4 py-2 bg-red-600 text-white rounded"
      >
        Try again
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Not Found Pages (not-found.tsx)

The not-found.tsx file is used to render UI when a route cannot be found, or when the notFound() function is explicitly called.

  • Triggering: It renders automatically for unmatched URLs, or programmatically when you call notFound() in a Server Component or Route Handler (e.g., if a database query for an ID returns null).
  • Scope: Like error.tsx, it's scoped to its segment. A not-found.tsx in app/dashboard only applies to unmatched routes within the dashboard.

Example:

// app/blog/[id]/page.tsx
import { notFound } from 'next/navigation';

export default async function BlogPost({ params }: { params: { id: string } }) {
  const post = await db.post.findUnique(params.id);

  if (!post) {
    // This will trigger the closest not-found.tsx boundary
    notFound(); 
  }

  return <article>{post.content}</article>;
}
Enter fullscreen mode Exit fullscreen mode

Example 2 (UI):

// app/blog/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div className="text-center py-20">
      <h2>Blog Post Not Found</h2>
      <p>Could not find requested resource</p>
      <Link href="/blog" className="text-blue-500 underline">Return to Blog</Link>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Built-in Optimizations and SEO

Next.js provides built-in components and APIs designed to drastically improve performance (Core Web Vitals) and SEO out of the box.

Image Optimization (next/image)

The <Image> component extends the standard HTML <img> element with powerful automatic optimizations.

  • Size Optimization: Automatically serves correctly sized images for each device, using modern formats like WebP and AVIF.
  • Visual Stability: Prevents Cumulative Layout Shift (CLS) automatically when images load.
  • Faster Page Loads: Images are lazily loaded by default (only loaded when they enter the viewport) with optional blur-up placeholders.
  • Asset Flexibility: Can optimize remote images (if configured in next.config.ts) as well as local assets.

Example:

import Image from 'next/image';
import profilePic from './me.png'; // Local image

export default function Profile() {
  return (
    <div>
      {/* Local Image (width/height automatically determined) */}
      <Image
        src={profilePic}
        alt="Picture of the author"
        placeholder="blur" // Optional blur-up while loading
      />

      {/* Remote Image (requires width, height, and config) */}
      <Image
        src="https://example.com/hero.jpg"
        alt="Hero image"
        width={500}
        height={500}
        priority // Use 'priority' for images above the fold (LCP)
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Font Optimization (next/font)

next/font automatically optimizes your fonts (including custom fonts) and removes external network requests for improved privacy and performance.

  • Self-Hosting: It downloads Google Fonts at build time and serves them locally, so there are no requests to Google servers from the browser.
  • No Layout Shift: It uses a CSS size-adjust property fallback to ensure there is zero layout shift when the font swaps.

Example:

// app/layout.tsx
import { Inter } from 'next/font/google';

// Configure the font subset
const inter = Inter({ subsets: ['latin'] });

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    // Apply the font class to the body
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Metadata and SEO API

Next.js has a built-in Metadata API that allows you to easily define SEO metadata (like meta tags, title, description, Open Graph images) for improved search engine rankings and social sharing.

  • Static Metadata: Export a metadata object from a layout.tsx or page.tsx.
  • Dynamic Metadata: Export a generateMetadata function to fetch data and dynamically set titles (e.g., for a blog post).
  • File-Based Metadata: You can add special files like favicon.ico, opengraph-image.png, robots.txt, and sitemap.ts directly in the app directory.

Example (Static & Dynamic):

// app/layout.tsx (Static)
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: {
    template: '%s | My Site',
    default: 'My Site - Home',
  },
  description: 'The best site ever.',
};

// app/products/[id]/page.tsx (Dynamic)
import type { Metadata, ResolvingMetadata } from 'next';

export async function generateMetadata(
  { params }: { params: { id: string } },
  parent: ResolvingMetadata
): Promise<Metadata> {
  const product = await fetchProduct(params.id);

  return {
    title: product.title,
    description: product.summary,
    openGraph: {
      images: [product.imageUrl],
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Data Fetching, Caching, and Revalidation

In Next.js 16, the caching model has shifted from "cache by default" to an explicit opt-in model. This gives you more control over what data is stored and for how long.

1. Default Caching Behavior (Next.js 16+)

Unlike previous versions (13/14), Next.js 16 does not cache fetch requests or components by default. Every request is considered dynamic unless you explicitly tell Next.js to cache it.

  • Standard Fetch: fetch('...') is equivalent to cache: 'no-store'.
  • Opt-in Required: You must use the 'use cache' directive to enable caching.

2. The 'use cache' Directive (Next.js 16+)

The 'use cache' directive is the modern way to cache data in Next.js. It can be applied at the file, component, or function level.

  • Function Level: Caches the return value of a specific asynchronous function.
  • Component Level: Caches the fully rendered HTML of a Server Component.
  • File Level: When placed at the top of a file, everything exported from that file is cached.

cacheLife: Controlling Duration

To control how long data stays in the cache, use the cacheLife function inside a 'use cache' scope. Next.js provides built-in profiles:

Profile Revalidate (Fresh) Expire (Stale) Use Case
seconds 1 second 1 minute Stock prices, scores
minutes 1 minute 1 hour Social media feeds
hours 1 hour 1 day Inventory, weather
days 1 day 1 week Blog posts, documentation
max 30 days 1 year Static assets, legal pages

Example: Caching with cacheLife

import { cacheLife } from 'next/cache';

async function getStockPrice(symbol: string) {
  'use cache';
  cacheLife('seconds'); // Revalidate every second

  const res = await fetch(`https://api.stocks.com/${symbol}`);
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

cacheTag: On-Demand Invalidation

To purge the cache when data changes (e.g., after a database update), use cacheTag to label your data and revalidateTag to clear it.

Example: Tagging and Updating

// 1. Tag the cached data
import { cacheTag, revalidateTag } from 'next/cache';

async function getPosts() {
  'use cache';
  cacheTag('posts-list'); // Assign a unique tag
  return db.posts.findMany();
}

// 2. Invalidate the tag in a Server Action
async function addPost(formData: FormData) {
  'use server';
  await db.posts.create({ data: { ... } });

  // This immediately purges all caches labeled 'posts-list'
  revalidateTag('posts-list'); 
}
Enter fullscreen mode Exit fullscreen mode

3. Migration and Summary Table

Next.js 16 provides a clear path for moving from the old model to the new one.

Method Execution Environment Use Case
'use cache' Server (Component/Function) Explicitly opt-in to caching
cacheLife('days') Server (within 'use cache') Set the lifetime of a cache entry
cacheTag('tag') Server (within 'use cache') Group related cache entries
revalidateTag('tag') Server Only (Actions/Handlers) Manually purge a group of caches
revalidatePath('/') Server Only (Actions/Handlers) Manually purge a specific route
router.refresh() Client Only (useRouter) Refresh the UI without losing state

4. Configuration

To use these features, enable them in your next.config.ts:

const nextConfig = {
  cacheComponents: true, // Enables 'use cache' and related APIs
};
Enter fullscreen mode Exit fullscreen mode

Identifying Static vs. Dynamic Routes

When you build your Next.js application, the framework provides a clear summary of which routes are static and which are dynamic. This is essential for understanding your app's performance and caching strategy.

How to Check:

Run the build command in your terminal:

npm run build
# OR
npx next build
Enter fullscreen mode Exit fullscreen mode

Understanding the Output:

At the end of the build process, you'll see a list of all your routes with a specific symbol next to them:

  • β—‹ (Empty Circle) - Static: This route is rendered into a static HTML file at build time. It is extremely fast and can be cached by a CDN (Content Delivery Network).
  • Ζ’ (Lambda/Function) - Dynamic: This route is rendered at request time (SSR). It runs on the server for every user request. This happens when you use dynamic functions (like cookies() or headers()) or fetch data with cache: 'no-store'.

Why it matters:

  • Static routes are served as pre-rendered HTML, providing the best performance and lowest server cost.
  • Dynamic routes allow you to show personalized or real-time data but require server resources for every visit.
  • If a route you expected to be static (circle) shows up as dynamic (lambda), check if you accidentally opted out of caching or used a dynamic function.

Thank you for reading! I hope this deep dive into Next.js helps you build amazing, high-performance applications. If you found this helpful or have any questions, feel free to reach out in the comments!

Top comments (2)

Collapse
 
scott_morrison_39a1124d85 profile image
Knowband

This is a very well-structured deep dive that connects fundamentals with real production patterns, especially around the App Router, caching, and server actions. I like how it doesn’t just explain features but shows how to combine them for scalable full-stack architecture, which is where most guides fall short

Collapse
 
preyumkr profile image
Preyum Kumar

Thanks for the sweet comment. I am greatful if it helped you.