<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged app-router</title>
<link>https://coderlegion.com/tag/app-router</link>
<description>Powered by Question2Answer</description>
<item>
<title>Error Handling in Next.js 16: The Complete 2026 Guide</title>
<link>https://coderlegion.com/24891/error-handling-in-next-js-16-the-complete-2026-guide</link>
<description>&lt;p&gt;Ever shipped a Next.js app, then watched one bad API response take down the &lt;em&gt;entire page&lt;/em&gt; — header, sidebar, footer, everything — just because one component threw an error? You're not alone. It's one of the most common &quot;gotchas&quot; for developers moving from &lt;code&gt;pages/&lt;/code&gt; to the App Router.&lt;/p&gt;
&lt;p&gt;The good news: Next.js 16 gives you a proper, file-based system for handling errors at exactly the level you want — a single component, a whole route segment, or the entire app. No more wrapping every component in a manual try/catch and hoping for the best.&lt;/p&gt;
&lt;p&gt;By the end of this guide, you'll know exactly which error file to use and when, how to handle errors in Server Actions and Route Handlers the right way, and how to avoid the mistakes that leave users staring at a blank white screen in production.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Situation         &lt;/th&gt;&lt;th&gt;Use&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Rendering error   &lt;/td&gt;&lt;td&gt;error.tsx&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Root layout crash &lt;/td&gt;&lt;td&gt;global-error.tsx&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Missing resource  &lt;/td&gt;&lt;td&gt;not-found.tsx&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Unknown URL       &lt;/td&gt;&lt;td&gt;global-not-found.tsx&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Form submission   &lt;/td&gt;&lt;td&gt;Server Action&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;API endpoint      &lt;/td&gt;&lt;td&gt;Route Handler&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;why-this-is-tricky&quot;&gt;Why This Is Tricky&lt;/h3&gt;
&lt;p&gt;In the old Pages Router, error handling meant a single &lt;code&gt;_error.js&lt;/code&gt; file for your whole app — crude, but simple. The App Router splits rendering into &lt;strong&gt;Server Components&lt;/strong&gt;, &lt;strong&gt;Client Components&lt;/strong&gt;, &lt;strong&gt;Server Actions&lt;/strong&gt;, and &lt;strong&gt;Route Handlers&lt;/strong&gt;, and each one fails differently:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A Server Component can throw during data fetching&lt;/li&gt;
&lt;li&gt;A Client Component can throw during rendering&lt;/li&gt;
&lt;li&gt;A Server Action can fail during a form submission&lt;/li&gt;
&lt;li&gt;A Route Handler (API route) can fail while processing a request&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each of these needs its own handling strategy — that's why Next.js gives you multiple tools instead of one. Let's go through them one by one.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Prerequisites:&lt;/strong&gt; This guide uses &lt;strong&gt;Next.js 16 (App Router)&lt;/strong&gt;, &lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode, &lt;strong&gt;React Compiler&lt;/strong&gt; (enabled by default, so no manual &lt;code&gt;useMemo&lt;/code&gt;/&lt;code&gt;useCallback&lt;/code&gt; needed), and &lt;strong&gt;Tailwind CSS&lt;/strong&gt; for styling. Node.js 20.9+ is required.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;step-1-catch-rendering-errors-with-error-tsx&quot;&gt;Step 1: Catch Rendering Errors with &lt;code&gt;error.tsx&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;An &lt;strong&gt;error boundary&lt;/strong&gt; is a component that catches errors happening below it in the tree and shows a fallback UI instead of crashing everything. Next.js builds this in automatically — you just add a file called &lt;code&gt;error.tsx&lt;/code&gt; to any route segment (a folder inside &lt;code&gt;app/&lt;/code&gt;).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/error.tsx
&quot;use client&quot;; // error.tsx must always be a Client Component

import { useEffect } from &quot;react&quot;;

interface ErrorPageProps {
  error: Error &amp;amp; { digest?: string };
  reset: () =&amp;gt; void;
}

const DashboardError = ({ error, reset }: ErrorPageProps) =&amp;gt; {
  useEffect(() =&amp;gt; {
    // Send the error to your monitoring tool (Sentry, Datadog, etc.)
    console.error(&quot;Dashboard error:&quot;, error);
  }, [error]);

  return (
    &amp;lt;div className=&quot;flex flex-col items-center justify-center gap-4 p-10 text-center&quot;&amp;gt;
      &amp;lt;h2 className=&quot;text-xl font-bold&quot;&amp;gt;Something went wrong&amp;lt;/h2&amp;gt;
      &amp;lt;p className=&quot;text-sm text-gray-500&quot;&amp;gt;
        We couldn&amp;amp;apos;t load your dashboard. This has been logged.
      &amp;lt;/p&amp;gt;
      &amp;lt;button
        onClick={() =&amp;gt; reset()}
        className=&quot;rounded-md bg-black px-4 py-2 text-white&quot;
      &amp;gt;
        Try again
      &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
};

export default DashboardError;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What just happened:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Next.js automatically wraps the route segment in a React error boundary and renders this file when a rendering error occurs&lt;/li&gt;
&lt;li&gt;&lt;code&gt;error&lt;/code&gt; is the thrown error object. In production, Next.js strips sensitive details and only sends a generic message plus a &lt;code&gt;digest&lt;/code&gt; — a hash you can match against your server logs&lt;/li&gt;
&lt;li&gt;&lt;code&gt;reset()&lt;/code&gt; tells React to try rendering the segment again — useful for transient errors like a flaky network request&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;useEffect&lt;/code&gt; is where you report the error to a monitoring service, since &lt;code&gt;error.tsx&lt;/code&gt; won't automatically show up in your logging tool&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Putting &lt;code&gt;error.tsx&lt;/code&gt; at the root of &lt;code&gt;app/&lt;/code&gt; and expecting it to catch everything. It can't — see Step 2.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;scoping-errors-to-the-right-level&quot;&gt;Scoping Errors to the Right Level&lt;/h4&gt;
&lt;p&gt;Place &lt;code&gt;error.tsx&lt;/code&gt; inside the specific folder you want to protect. A crash in one section won't take down the rest of the app.&lt;br&gt;
Think of &lt;code&gt;error.tsx&lt;/code&gt; like nested try/catch blocks. Next.js always looks for the nearest boundary first, then continues bubbling upward until it finds one.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;app/
  dashboard/
    layout.tsx        ← sidebar stays visible even if a child crashes
    page.tsx
    error.tsx          ← catches errors from all dashboard routes
    analytics/
      page.tsx
      error.tsx        ← catches errors only in /dashboard/analytics
    settings/
      page.tsx         ← no error.tsx here, so errors bubble up to dashboard/error.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;/dashboard/analytics&lt;/code&gt; crashes, only that section shows the fallback — the sidebar in &lt;code&gt;layout.tsx&lt;/code&gt; keeps working. If &lt;code&gt;/dashboard/settings&lt;/code&gt; crashes, since it has no &lt;code&gt;error.tsx&lt;/code&gt; of its own, the error bubbles up to &lt;code&gt;dashboard/error.tsx&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-rendering-strategies-explained&quot;&gt;Related: Next.js Rendering Strategies Explained — including Streaming with Suspense&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;step-2-handle-root-layout-errors-with-global-error-tsx&quot;&gt;Step 2: Handle Root Layout Errors with &lt;code&gt;global-error.tsx&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;There's one blind spot &lt;code&gt;error.tsx&lt;/code&gt; can't cover: &lt;strong&gt;errors thrown inside the root layout itself&lt;/strong&gt; (&lt;code&gt;app/layout.tsx&lt;/code&gt;). Since the root layout wraps your entire app — including every &lt;code&gt;error.tsx&lt;/code&gt; boundary — it needs its own separate mechanism.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/global-error.tsx
&quot;use client&quot;;

interface GlobalErrorProps {
  error: Error &amp;amp; { digest?: string };
  reset: () =&amp;gt; void;
}

const GlobalError = ({ error, reset }: GlobalErrorProps) =&amp;gt; {
  return (
    &amp;lt;html&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;div style={{ textAlign: &quot;center&quot;, padding: &quot;3rem&quot; }}&amp;gt;
          &amp;lt;h2&amp;gt;Something went seriously wrong&amp;lt;/h2&amp;gt;
          &amp;lt;p&amp;gt;We&amp;amp;apos;ve been notified and are looking into it.&amp;lt;/p&amp;gt;
          &amp;lt;button onClick={() =&amp;gt; reset()}&amp;gt;Try again&amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
};

export default GlobalError;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What just happened:&lt;/strong&gt; Because &lt;code&gt;global-error.tsx&lt;/code&gt; replaces the entire root layout when it triggers, it has to define its own &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; tags — your normal fonts, providers, and navigation won't be available. Keep this file as simple and dependency-free as possible, since if the root layout crashed, you can't assume anything else in your app still works.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; For most apps, you'll rarely see &lt;code&gt;global-error.tsx&lt;/code&gt; fire — root layout crashes are uncommon. But always keep the file in place as a safety net so users never see a completely broken, unstyled page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;step-3-handle-not-found-cases&quot;&gt;Step 3: Handle &quot;Not Found&quot; Cases&lt;/h3&gt;
&lt;p&gt;A 404 happens when a user hits a URL that doesn't exist, or when &lt;em&gt;you&lt;/em&gt; explicitly know a resource is missing (like a product ID that doesn't exist in your database). Next.js 16 gives you two files for this.&lt;/p&gt;
&lt;h4 id=&quot;not-found-tsx-for-a-specific-segment&quot;&gt;&lt;code&gt;not-found.tsx&lt;/code&gt; for a Specific Segment&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/[slug]/not-found.tsx
const PostNotFound = () =&amp;gt; {
  return (
    &amp;lt;div className=&quot;p-10 text-center&quot;&amp;gt;
      &amp;lt;h2 className=&quot;text-2xl font-bold&quot;&amp;gt;Post not found&amp;lt;/h2&amp;gt;
      &amp;lt;p className=&quot;text-gray-500&quot;&amp;gt;This blog post doesn&amp;amp;apos;t exist or was removed.&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
};

export default PostNotFound;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Trigger it explicitly by calling the &lt;code&gt;notFound()&lt;/code&gt; function anywhere in a Server Component:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/[slug]/page.tsx
import { notFound } from &quot;next/navigation&quot;;
import { getPostBySlug } from &quot;@/lib/posts&quot;;

interface Props {
  params: Promise&amp;lt;{ slug: string }&amp;gt;; // params is always a Promise in Next.js 16
}

const BlogPostPage = async ({ params }: Props) =&amp;gt; {
  const { slug } = await params; // must be awaited
  const post = await getPostBySlug(slug);

  if (!post) {
    notFound(); // renders the nearest not-found.tsx
  }

  return &amp;lt;article&amp;gt;{post.content}&amp;lt;/article&amp;gt;;
};

export default BlogPostPage;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What just happened:&lt;/strong&gt; &lt;code&gt;notFound()&lt;/code&gt; throws a special signal that Next.js catches internally and renders the closest &lt;code&gt;not-found.tsx&lt;/code&gt; up the tree — similar to how &lt;code&gt;error.tsx&lt;/code&gt; catches thrown errors, but specifically for missing content. It also correctly returns a 404 HTTP status, which matters for SEO.&lt;/p&gt;
&lt;h4 id=&quot;global-not-found-tsx-for-unmatched-urls-new-in-next-js-16-&quot;&gt;&lt;code&gt;global-not-found.tsx&lt;/code&gt; for Unmatched URLs (New in Next.js 16)&lt;/h4&gt;
&lt;p&gt;If someone visits a URL that doesn't match any route at all — a typo, an old bookmarked link — Next.js needs a catch-all 404 page for your whole app. In most apps, a root-level &lt;code&gt;app/not-found.tsx&lt;/code&gt; already handles this. But if your app has &lt;strong&gt;multiple root layouts&lt;/strong&gt; (like separate &lt;code&gt;(admin)&lt;/code&gt; and &lt;code&gt;(shop)&lt;/code&gt; groups) or a root layout built on a dynamic segment like &lt;code&gt;app/[country]/layout.tsx&lt;/code&gt;, there's no single layout to render a 404 through — which is exactly the gap &lt;code&gt;global-not-found.tsx&lt;/code&gt; closes.&lt;/p&gt;
&lt;p&gt;First, enable it in your config:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// next.config.ts
import type { NextConfig } from &quot;next&quot;;

const nextConfig: NextConfig = {
  experimental: {
    globalNotFound: true,
  },
};

export default nextConfig;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then create the file at the root of &lt;code&gt;app/&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/global-not-found.tsx
const GlobalNotFound = () =&amp;gt; {
  return (
    &amp;lt;html&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;div style={{ textAlign: &quot;center&quot;, padding: &quot;3rem&quot; }}&amp;gt;
          &amp;lt;h1&amp;gt;404 — Page not found&amp;lt;/h1&amp;gt;
          &amp;lt;p&amp;gt;The page you&amp;amp;apos;re looking for doesn&amp;amp;apos;t exist.&amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
};

export default GlobalNotFound;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Like &lt;code&gt;global-error.tsx&lt;/code&gt;, this file renders outside your normal root layout, so it needs its own &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; tags too.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;step-4-handle-errors-in-server-actions&quot;&gt;Step 4: Handle Errors in Server Actions&lt;/h3&gt;
&lt;p&gt;Server Actions are trickier than page rendering — a thrown error there doesn't show your &lt;code&gt;error.tsx&lt;/code&gt; UI cleanly, it just rejects the promise. The production-safe pattern is to avoid throwing for expected failures such as validation errors, and instead return a typed result the client can check.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/posts.ts
&quot;use server&quot;;

import { z } from &quot;zod&quot;;
import { db } from &quot;@/lib/db&quot;;
import { updateTag } from &quot;next/cache&quot;;

const CreatePostSchema = z.object({
  title: z.string().min(1, &quot;Title is required&quot;),
  body: z.string().min(1, &quot;Body is required&quot;),
});

export const createPost = async (_prev: unknown, formData: FormData) =&amp;gt; {
  const parsed = CreatePostSchema.safeParse({
    title: formData.get(&quot;title&quot;),
    body: formData.get(&quot;body&quot;),
  });

  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }

  try {
    const post = await db.post.create({ data: parsed.data });
    updateTag(&quot;posts&quot;); // instantly reflects the new post
    return { data: post };
  } catch (err) {
    console.error(&quot;Failed to create post:&quot;, err);
    return { error: { _form: [&quot;Something went wrong. Please try again.&quot;] } };
  }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then consume it on the client with &lt;code&gt;useActionState&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/posts/_new-post-client.tsx
&quot;use client&quot;;

import { useActionState } from &quot;react&quot;;
import { createPost } from &quot;@/actions/posts&quot;;

export const NewPostForm = () =&amp;gt; {
  const [state, formAction, isPending] = useActionState(createPost, null);

  return (
    &amp;lt;form action={formAction} className=&quot;flex flex-col gap-3&quot;&amp;gt;
      &amp;lt;input name=&quot;title&quot; placeholder=&quot;Title&quot; className=&quot;border p-2&quot; /&amp;gt;
      {state?.error &amp;amp;&amp;amp; &quot;title&quot; in state.error &amp;amp;&amp;amp; (
        &amp;lt;p className=&quot;text-sm text-red-500&quot;&amp;gt;{state.error.title?.[0]}&amp;lt;/p&amp;gt;
      )}

      &amp;lt;textarea name=&quot;body&quot; placeholder=&quot;Body&quot; className=&quot;border p-2&quot; /&amp;gt;

      &amp;lt;button disabled={isPending} className=&quot;rounded-md bg-black px-4 py-2 text-white&quot;&amp;gt;
        {isPending ? &quot;Publishing...&quot; : &quot;Publish&quot;}
      &amp;lt;/button&amp;gt;
    &amp;lt;/form&amp;gt;
  );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What just happened:&lt;/strong&gt; &lt;code&gt;safeParse&lt;/code&gt; validates the form data with Zod before it ever touches your database — this catches bad input early and cheaply. The &lt;code&gt;try/catch&lt;/code&gt; around the database call handles &lt;em&gt;unexpected&lt;/em&gt; failures (a dropped connection, a constraint violation) and turns them into a friendly &lt;code&gt;{ error }&lt;/code&gt; object instead of an unhandled crash. &lt;code&gt;useActionState&lt;/code&gt; gives you &lt;code&gt;state&lt;/code&gt; (the last returned value), &lt;code&gt;formAction&lt;/code&gt; (to bind to the form), and &lt;code&gt;isPending&lt;/code&gt; (to disable the button while submitting) — all without extra &lt;code&gt;useState&lt;/code&gt; boilerplate.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; The rule of thumb: validation errors and expected failures → return &lt;code&gt;{ error }&lt;/code&gt;. Truly unexpected, unrecoverable errors → let them throw so Next.js's error boundary can catch them upstream.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;step-5-handle-errors-in-route-handlers-api-routes-&quot;&gt;Step 5: Handle Errors in Route Handlers (API Routes)&lt;/h3&gt;
&lt;p&gt;Route Handlers don't have an error boundary at all — if you don't catch an error yourself, the client just gets a generic 500 response with no useful detail.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/posts/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { z } from &quot;zod&quot;;
import { db } from &quot;@/lib/db&quot;;

const PostSchema = z.object({
  title: z.string().min(1),
  body: z.string().min(1),
});

export const POST = async (req: NextRequest) =&amp;gt; {
  try {
    const body: unknown = await req.json();
    const parsed = PostSchema.safeParse(body);

    if (!parsed.success) {
      return NextResponse.json(
        { error: parsed.error.flatten() },
        { status: 400 },
      );
    }

    const post = await db.post.create({ data: parsed.data });
    return NextResponse.json(post, { status: 201 });
  } catch (err) {
    console.error(&quot;POST /api/posts failed:&quot;, err);
    return NextResponse.json(
      { error: &quot;Internal server error&quot; },
      { status: 500 },
    );
  }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What just happened:&lt;/strong&gt; Every branch returns a proper &lt;code&gt;NextResponse&lt;/code&gt; with an accurate status code — 400 for bad input the client sent, 500 for something that broke on your end. Wrapping the whole handler body in &lt;code&gt;try/catch&lt;/code&gt; means a database outage returns a clean JSON error instead of the request just hanging or crashing the server process.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Reserve 4xx responses for problems caused by the client (validation, authentication, missing resources) and 5xx responses for unexpected server failures. This distinction makes your API easier to debug and integrates better with monitoring systems.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Returning &lt;code&gt;err.message&lt;/code&gt; directly to the client in the 500 response. Internal error messages can leak details about your database or file structure — log the real error server-side, and send the client a generic message.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;frequently-asked-questions&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;does-error-tsx-catch-errors-in-event-handlers-like-an-onclick-&quot;&gt;Does &lt;code&gt;error.tsx&lt;/code&gt; catch errors in event handlers, like an &lt;code&gt;onClick&lt;/code&gt;?&lt;/h4&gt;
&lt;p&gt;No. Error boundaries only catch errors thrown &lt;em&gt;during rendering&lt;/em&gt; — not inside event handlers, &lt;code&gt;setTimeout&lt;/code&gt; callbacks, or async code running after the render finished. Wrap those in a regular &lt;code&gt;try/catch&lt;/code&gt; inside the handler itself.&lt;/p&gt;
&lt;h4 id=&quot;can-error-tsx-be-a-server-component-&quot;&gt;Can &lt;code&gt;error.tsx&lt;/code&gt; be a Server Component?&lt;/h4&gt;
&lt;p&gt;No, it must always start with &lt;code&gt;&quot;use client&quot;&lt;/code&gt;. React error boundaries are a client-side concept, and Next.js enforces this at build time.&lt;/p&gt;
&lt;h4 id=&quot;what-s-the-actual-difference-between-error-tsx-and-not-found-tsx-&quot;&gt;What's the actual difference between &lt;code&gt;error.tsx&lt;/code&gt; and &lt;code&gt;not-found.tsx&lt;/code&gt;?&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;error.tsx&lt;/code&gt; catches &lt;em&gt;unexpected&lt;/em&gt; thrown errors — bugs, failed fetches, crashes. &lt;code&gt;not-found.tsx&lt;/code&gt; is for the &lt;em&gt;expected&lt;/em&gt; case of &quot;this specific thing doesn't exist,&quot; triggered on purpose by calling &lt;code&gt;notFound()&lt;/code&gt;. Different problem, different UI, different HTTP status code.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-global-error-tsx-if-i-already-have-error-tsx-everywhere-&quot;&gt;Do I need &lt;code&gt;global-error.tsx&lt;/code&gt; if I already have &lt;code&gt;error.tsx&lt;/code&gt; everywhere?&lt;/h4&gt;
&lt;p&gt;Yes, keep it. &lt;code&gt;error.tsx&lt;/code&gt; files can't catch errors thrown in the root layout itself (things like a provider that crashes on init), since the root layout wraps every &lt;code&gt;error.tsx&lt;/code&gt; boundary you have.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Think of it like a fuse box — &lt;code&gt;error.tsx&lt;/code&gt; protects individual circuits (routes), while &lt;code&gt;global-error.tsx&lt;/code&gt; is the master breaker for the whole house.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;should-i-use-sentry-or-a-similar-tool-instead-of-just-console-error-&quot;&gt;Should I use Sentry or a similar tool instead of just &lt;code&gt;console.error&lt;/code&gt;?&lt;/h4&gt;
&lt;p&gt;For production apps, yes. &lt;code&gt;console.error&lt;/code&gt; inside &lt;code&gt;error.tsx&lt;/code&gt; is a starting point, but without a monitoring service you won't know an error happened until a user complains. Most teams call &lt;code&gt;Sentry.captureException(error)&lt;/code&gt; (or an equivalent) inside the &lt;code&gt;useEffect&lt;/code&gt; in &lt;code&gt;error.tsx&lt;/code&gt;, and wrap Server Actions with the framework's server-action instrumentation to link client and server error traces together.&lt;/p&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;You now know how to scope error boundaries with &lt;code&gt;error.tsx&lt;/code&gt;, catch root-layout crashes with &lt;code&gt;global-error.tsx&lt;/code&gt;, handle missing content with &lt;code&gt;not-found.tsx&lt;/code&gt; and the new &lt;code&gt;global-not-found.tsx&lt;/code&gt;, and return safe, typed errors from Server Actions and Route Handlers instead of letting them crash silently. Put these in place &lt;em&gt;before&lt;/em&gt; you need them — retrofitting error handling after a production incident is a much worse day than setting it up now.&lt;/p&gt;
&lt;p&gt;Next, it's worth pairing this with proper loading states — &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-rendering-strategies-explained&quot;&gt;Next.js Rendering Strategies Explained&lt;/a&gt; covers Suspense streaming so your loading and error boundaries work together for a fully resilient UI.&lt;/p&gt;
&lt;h3 id=&quot;continue-learning&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-route-handlers&quot;&gt;Route Handlers Explained in Next.js 16 (Beginner's Guide)&lt;/a&gt; — goes deeper into building typed API endpoints, the same ones you're wrapping in &lt;code&gt;try/catch&lt;/code&gt; in Step 5.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-rendering-strategies-explained&quot;&gt;Next.js Rendering Strategies Explained: SSR, SSG, CSR, ISR &amp;amp; Cache Components (2026)&lt;/a&gt; — pairs naturally with error boundaries since Suspense streaming and error handling are designed to work together.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-routing-guide&quot;&gt;Next.js 16 Routing Explained: A Complete Beginner's Guide (2026)&lt;/a&gt; — covers how &lt;code&gt;error.tsx&lt;/code&gt;, &lt;code&gt;not-found.tsx&lt;/code&gt;, and other special files fit into the App Router's file-system conventions.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/zod-vs-yup&quot;&gt;Zod vs Yup in 2026: Which Validation Library Should You Use?&lt;/a&gt; — if you want a deeper look at the &lt;code&gt;safeParse&lt;/code&gt; validation pattern used in the Server Actions example above.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24891/error-handling-in-next-js-16-the-complete-2026-guide</guid>
<pubDate>Wed, 19 Aug 2026 06:18:11 +0000</pubDate>
</item>
<item>
<title>Route Handlers Explained in Next.js 16 (Beginner's Guide)</title>
<link>https://coderlegion.com/24571/route-handlers-explained-in-next-js-16-beginners-guide</link>
<description>&lt;p&gt;You've built pages, you've built layouts, and now you need something that isn't a page at all — a webhook endpoint for Stripe, a JSON API for a mobile app, or a route that generates a CSV file on demand. None of that fits inside a &lt;code&gt;page.tsx&lt;/code&gt;. That's exactly the gap &lt;strong&gt;Route Handlers&lt;/strong&gt; fill in the Next.js App Router.&lt;/p&gt;
&lt;p&gt;If you've used the old Pages Router, Route Handlers are the direct replacement for &lt;code&gt;pages/api&lt;/code&gt;. If you're coming from Express or another Node framework, think of them as your &lt;code&gt;app.get()&lt;/code&gt; and &lt;code&gt;app.post()&lt;/code&gt; — just organized by folder structure instead of a router file.&lt;/p&gt;
&lt;p&gt;By the end of this guide, you'll know exactly how Route Handlers work in Next.js 16, how they're different from Server Actions, how caching behaves with the new Cache Components model, and how to build a real, typed API endpoint — including handling dynamic routes and webhooks.&lt;/p&gt;
&lt;p&gt;Here's the whole picture at a glance before we dive in step by step:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://devstacked.tech/route-handlers-nextjs/image1.svg&quot; alt=&quot;Diagram showing a client request flowing into a Next.js route.ts Route Handler, which optionally calls a database or third-party API, then returns a Response back to the client&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-are-route-handlers-exactly-&quot;&gt;What Are Route Handlers, Exactly?&lt;/h3&gt;
&lt;p&gt;A Route Handler is a function that runs on the server and responds to an HTTP request — no HTML, no React, just data. You define one inside a special file named &lt;code&gt;route.ts&lt;/code&gt;, and it can live anywhere inside your &lt;code&gt;app&lt;/code&gt; directory.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/hello/route.ts
export async function GET() {
  return Response.json({ message: &quot;Hello from Next.js 16!&quot; })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Visit &lt;code&gt;/api/hello&lt;/code&gt; in your browser, and you'll get back that JSON — no page rendered, just a raw HTTP response.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Route Handlers use the standard Web &lt;code&gt;Request&lt;/code&gt; and &lt;code&gt;Response&lt;/code&gt; APIs — the same ones built into browsers and modern JavaScript runtimes — not the old Express-style &lt;code&gt;req&lt;/code&gt;/&lt;code&gt;res&lt;/code&gt; objects. That means the skills you learn here also apply outside Next.js.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;why-this-is-tricky-for-beginners-&quot;&gt;Why This Is Tricky (For Beginners)&lt;/h3&gt;
&lt;p&gt;New Next.js developers usually hit two points of confusion:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&quot;Isn't this just an API Route?&quot;&lt;/strong&gt; — Sort of, but Route Handlers live in &lt;code&gt;app/&lt;/code&gt; and use a completely different file convention (&lt;code&gt;route.ts&lt;/code&gt; instead of pulling from &lt;code&gt;pages/api&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&quot;Should I use a Route Handler or a Server Action?&quot;&lt;/strong&gt; — Both let you run server-side code, but they solve different problems, and picking the wrong one leads to unnecessary complexity later.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Let's clear both of those up before touching any code.&lt;/p&gt;
&lt;h4 id=&quot;route-handlers-vs-pages-router-api-routes&quot;&gt;Route Handlers vs. Pages Router API Routes&lt;/h4&gt;
&lt;p&gt;If you're migrating from the Pages Router, &lt;code&gt;pages/api/hello.ts&lt;/code&gt; becomes &lt;code&gt;app/api/hello/route.ts&lt;/code&gt;. The concept is identical — a folder-based endpoint — but the syntax changes from a single default export to named exports per HTTP method.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Good to know:&lt;/strong&gt; You never need to mix API Routes and Route Handlers in the same project. If your project uses the &lt;code&gt;app&lt;/code&gt; directory, Route Handlers are your only option for API-style endpoints — the Pages Router pattern doesn't apply anymore.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;route-handlers-vs-server-actions&quot;&gt;Route Handlers vs. Server Actions&lt;/h4&gt;
&lt;p&gt;This is the question that trips up almost everyone, so here's the short version:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Route Handlers &lt;/th&gt;&lt;th&gt;Server Actions&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Called from &lt;/td&gt;&lt;td&gt;Anywhere (browser, mobile app, another server, &lt;code&gt;curl&lt;/code&gt;) &lt;/td&gt;&lt;td&gt;Only from inside your own React components&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Best for &lt;/td&gt;&lt;td&gt;Public APIs, webhooks, third-party integrations &lt;/td&gt;&lt;td&gt;Form submissions, internal data mutations&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;HTTP control &lt;/td&gt;&lt;td&gt;Full — you choose methods, headers, status codes &lt;/td&gt;&lt;td&gt;Handled for you automatically&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;URL &lt;/td&gt;&lt;td&gt;Has a real, callable URL &lt;/td&gt;&lt;td&gt;No public URL of its own&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; if something &lt;em&gt;outside&lt;/em&gt; your Next.js app (a webhook from Stripe, a mobile client, a public API consumer) needs to call this code, use a Route Handler. If it's a form or button inside your own app calling a server function, a Server Action is simpler and does less boilerplate for you.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-rendering-strategies-explained&quot;&gt;Related: Next.js Rendering Strategies Explained: SSR, SSG, CSR, ISR &amp;amp; Cache Components&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;This guide uses:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;React Compiler&lt;/strong&gt; enabled (not directly relevant to Route Handlers, but assumed as the project default)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You don't need any extra packages — everything here ships with Next.js out of the box.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-the-route-ts-file-convention&quot;&gt;Step 1: The &lt;code&gt;route.ts&lt;/code&gt; File Convention&lt;/h3&gt;
&lt;p&gt;A Route Handler is created by adding a &lt;code&gt;route.ts&lt;/code&gt; (or &lt;code&gt;route.js&lt;/code&gt;) file inside any folder in &lt;code&gt;app/&lt;/code&gt;. The folder path becomes the URL.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;app/
└── api/
    └── hello/
        └── route.ts       → /api/hello
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/hello/route.ts
export async function GET(request: Request) {
  return Response.json({ message: &quot;Hello, world!&quot; })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; exporting a function named &lt;code&gt;GET&lt;/code&gt; tells Next.js &quot;handle GET requests to this route with this function.&quot; The function receives the standard Web &lt;code&gt;Request&lt;/code&gt; object and must return a &lt;code&gt;Response&lt;/code&gt; (or &lt;code&gt;NextResponse&lt;/code&gt;, which extends it).&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; A &lt;code&gt;route.ts&lt;/code&gt; file &lt;strong&gt;cannot&lt;/strong&gt; live in the same folder as a &lt;code&gt;page.tsx&lt;/code&gt; at the same segment. &lt;code&gt;app/page.tsx&lt;/code&gt; and &lt;code&gt;app/route.ts&lt;/code&gt; together will throw a build error — Next.js doesn't know whether that URL should render a page or run an API handler. Nest your Route Handler under a different segment, like &lt;code&gt;app/api/...&lt;/code&gt;, instead.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;step-2-supported-http-methods&quot;&gt;Step 2: Supported HTTP Methods&lt;/h3&gt;
&lt;p&gt;Route Handlers support the standard HTTP verbs as named exports in the same file: &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, &lt;code&gt;PATCH&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;HEAD&lt;/code&gt;, and &lt;code&gt;OPTIONS&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/todos/route.ts
export async function GET() {
  const todos = await getTodosFromDb()
  return Response.json(todos)
}

export async function POST(request: Request) {
  const body = await request.json()
  const newTodo = await createTodo(body)
  return Response.json(newTodo, { status: 201 })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; one file now handles two different behaviors depending on the incoming HTTP method — a &lt;code&gt;GET&lt;/code&gt; request lists todos, a &lt;code&gt;POST&lt;/code&gt; request creates one. If a client sends a method you haven't exported (like &lt;code&gt;DELETE&lt;/code&gt; on a file with only &lt;code&gt;GET&lt;/code&gt;/&lt;code&gt;POST&lt;/code&gt;), Next.js automatically responds with a &lt;code&gt;405 Method Not Allowed&lt;/code&gt; — you don't need to handle that yourself.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Next.js also auto-implements a sensible &lt;code&gt;OPTIONS&lt;/code&gt; response for you if you don't define one, which is useful for CORS preflight requests (more on that below).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;response-vs-nextresponse-request-vs-nextrequest&quot;&gt;Response vs. NextResponse, Request vs. NextRequest&lt;/h3&gt;
&lt;p&gt;Before going further, it's worth clearing up which object to reach for — they show up interchangeably in examples online, which confuses a lot of beginners.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Request&lt;/code&gt; / &lt;code&gt;Response&lt;/code&gt;&lt;/strong&gt; — the plain Web APIs, built into the JavaScript runtime itself, not Next.js-specific. Use these when you don't need anything extra — a simple JSON response, or reading a body with &lt;code&gt;request.json()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;NextRequest&lt;/code&gt;&lt;/strong&gt; (from &lt;code&gt;next/server&lt;/code&gt;) — extends &lt;code&gt;Request&lt;/code&gt; with Next.js-only conveniences, most notably &lt;code&gt;request.nextUrl&lt;/code&gt; (an already-parsed URL object) and &lt;code&gt;request.cookies&lt;/code&gt;. Reach for this the moment you need query params or cookies without manually parsing the URL string.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;NextResponse&lt;/code&gt;&lt;/strong&gt; (from &lt;code&gt;next/server&lt;/code&gt;) — extends &lt;code&gt;Response&lt;/code&gt; with helpers like &lt;code&gt;NextResponse.redirect()&lt;/code&gt;, &lt;code&gt;NextResponse.rewrite()&lt;/code&gt;, and easier cookie-setting via &lt;code&gt;response.cookies.set()&lt;/code&gt;. Use this when you need to redirect, rewrite, or set cookies on the way out.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; A good default: start with plain &lt;code&gt;Request&lt;/code&gt;/&lt;code&gt;Response.json()&lt;/code&gt; for simple handlers, and upgrade to &lt;code&gt;NextRequest&lt;/code&gt;/&lt;code&gt;NextResponse&lt;/code&gt; only when you actually need one of their extra features. Both pairs are fully interoperable — a function typed to accept &lt;code&gt;NextRequest&lt;/code&gt; still works fine if you never end up using the extra methods.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;step-3-reading-data-from-the-request&quot;&gt;Step 3: Reading Data From the Request&lt;/h3&gt;
&lt;p&gt;Most real endpoints need to read something — a JSON body, a query parameter, or a header.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/search/route.ts
import { NextRequest } from &quot;next/server&quot;

export async function GET(request: NextRequest) {
  // Reading a query param: /api/search?q=nextjs
  const query = request.nextUrl.searchParams.get(&quot;q&quot;)

  if (!query) {
    return Response.json({ error: &quot;Missing 'q' parameter&quot; }, { status: 400 })
  }

  const results = await searchDatabase(query)
  return Response.json(results)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;NextRequest&lt;/code&gt; (imported from &lt;code&gt;next/server&lt;/code&gt;) extends the standard &lt;code&gt;Request&lt;/code&gt; with convenience helpers — &lt;code&gt;request.nextUrl&lt;/code&gt; gives you an already-parsed URL object, so &lt;code&gt;searchParams.get(&quot;q&quot;)&lt;/code&gt; is easier than manually parsing &lt;code&gt;request.url&lt;/code&gt; with the native &lt;code&gt;URL&lt;/code&gt; class.&lt;/p&gt;
&lt;p&gt;For a JSON body on a &lt;code&gt;POST&lt;/code&gt; or &lt;code&gt;PUT&lt;/code&gt; request, use &lt;code&gt;request.json()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/contact/route.ts
export async function POST(request: Request) {
  const { email, message } = await request.json()

  if (!email || !message) {
    return Response.json(
      { error: &quot;Email and message are required&quot; },
      { status: 400 }
    )
  }

  await sendContactEmail({ email, message })
  return Response.json({ success: true })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Never trust that &lt;code&gt;email&lt;/code&gt; and &lt;code&gt;message&lt;/code&gt; exist just because your frontend form requires them. Anyone can send a raw &lt;code&gt;POST&lt;/code&gt; request straight to &lt;code&gt;/api/contact&lt;/code&gt; with &lt;code&gt;curl&lt;/code&gt;, skipping your form entirely — always validate on the server, ideally with a schema library like Zod.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;step-4-dynamic-route-segments&quot;&gt;Step 4: Dynamic Route Segments&lt;/h3&gt;
&lt;p&gt;Just like pages, Route Handlers support dynamic segments using square brackets.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;app/
└── users/
    └── [id]/
        └── route.ts        → /users/123, /users/456, etc.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In Next.js 16, the recommended way to type the dynamic segment is the built-in &lt;code&gt;RouteContext&lt;/code&gt; helper — it's generated automatically for you based on your actual route structure.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/users/[id]/route.ts
import type { NextRequest } from &quot;next/server&quot;

export async function GET(
  request: NextRequest,
  ctx: RouteContext&amp;lt;&quot;/users/[id]&quot;&amp;gt;
) {
  const { id } = await ctx.params
  const user = await getUserById(id)

  if (!user) {
    return Response.json({ error: &quot;User not found&quot; }, { status: 404 })
  }

  return Response.json(user)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;ctx.params&lt;/code&gt; is a &lt;code&gt;Promise&lt;/code&gt; — this matches the same async &lt;code&gt;params&lt;/code&gt; convention used in &lt;code&gt;page.tsx&lt;/code&gt; files throughout Next.js 16, so you must &lt;code&gt;await&lt;/code&gt; it before reading &lt;code&gt;id&lt;/code&gt;. &lt;code&gt;RouteContext&amp;lt;&quot;/users/[id]&quot;&amp;gt;&lt;/code&gt; gives you full type-safety without manually writing an interface for every dynamic route.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Good to know:&lt;/strong&gt; These types are generated when you run &lt;code&gt;next dev&lt;/code&gt;, &lt;code&gt;next build&lt;/code&gt;, or &lt;code&gt;next typegen&lt;/code&gt; — if your editor shows a type error right after creating a new dynamic route, running one of those commands usually fixes it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;step-5-caching-behavior&quot;&gt;Step 5: Caching Behavior&lt;/h3&gt;
&lt;p&gt;This trips up a lot of people coming from the Pages Router, where API Routes were never cached. In the App Router, it's a little more nuanced.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The short version:&lt;/strong&gt; Route Handlers are &lt;strong&gt;not cached by default&lt;/strong&gt;. Only &lt;code&gt;GET&lt;/code&gt; handlers can opt into caching — every other method (&lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, etc.) is always dynamic, even if you configure caching in the same file.&lt;/p&gt;
&lt;p&gt;To force a &lt;code&gt;GET&lt;/code&gt; handler to be cached as static:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/items/route.ts
export const dynamic = &quot;force-static&quot;

export async function GET() {
  const res = await fetch(&quot;https://data.example.com/items&quot;)
  const data = await res.json()
  return Response.json({ data })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;if-you-re-using-cache-components-use-cache-&quot;&gt;If You're Using Cache Components (&lt;code&gt;&quot;use cache&quot;&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;If your project has &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-rendering-strategies-explained&quot;&gt;Cache Components&lt;/a&gt; enabled, &lt;code&gt;GET&lt;/code&gt; Route Handlers behave exactly like a normal page: they run dynamically by default, but can be pre-rendered at build time if they don't touch anything request-specific.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/project-info/route.ts
// No dynamic data — this gets pre-rendered at build time
export async function GET() {
  return Response.json({ projectName: &quot;DevStacked&quot; })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/user-agent/route.ts
import { headers } from &quot;next/headers&quot;

// Reads a request-specific header — this forces the route to run per-request
export async function GET() {
  const headersList = await headers()
  const userAgent = headersList.get(&quot;user-agent&quot;)
  return Response.json({ userAgent })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you need to mix cached data with per-request logic, pull the cached part into its own function using &lt;code&gt;&quot;use cache&quot;&lt;/code&gt; — you can't put &lt;code&gt;&quot;use cache&quot;&lt;/code&gt; directly inside the Route Handler body itself.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/products/route.ts
import { cacheLife } from &quot;next/cache&quot;

export async function GET() {
  const products = await getProducts()
  return Response.json(products)
}

async function getProducts() {
  &quot;use cache&quot;
  cacheLife(&quot;hours&quot;)

  return await db.query(&quot;SELECT * FROM products&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Prerendering for a &lt;code&gt;GET&lt;/code&gt; handler stops the moment it touches anything non-deterministic — a database call without &lt;code&gt;&quot;use cache&quot;&lt;/code&gt;, &lt;code&gt;Math.random()&lt;/code&gt;, or runtime APIs like &lt;code&gt;cookies()&lt;/code&gt; and &lt;code&gt;headers()&lt;/code&gt;. If a route you expected to be static is showing up dynamic in your build output, one of these is almost always why.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;a-quick-word-on-the-edge-runtime&quot;&gt;A Quick Word on the Edge Runtime&lt;/h4&gt;
&lt;p&gt;By default, Route Handlers run on the &lt;strong&gt;Node.js runtime&lt;/strong&gt; — the full Node environment, with access to everything you'd expect (the &lt;code&gt;fs&lt;/code&gt; module, native npm packages, longer execution limits). You can opt a specific route into the lighter-weight &lt;strong&gt;Edge Runtime&lt;/strong&gt; instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/geo/route.ts
export const runtime = &quot;edge&quot;

export async function GET() {
  return Response.json({ message: &quot;Running on the Edge&quot; })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Edge Runtime starts faster and runs closer to the user geographically, but it's a stripped-down environment — no &lt;code&gt;fs&lt;/code&gt;, no full Node API surface, and not every npm package supports it. Reach for it for small, latency-sensitive handlers (geolocation checks, lightweight redirects); stick with the Node.js default for anything doing real database work, file access, or using Node-only packages like the Stripe or AWS SDKs.&lt;/p&gt;
&lt;h3 id=&quot;step-6-handling-webhooks-raw-body-access-&quot;&gt;Step 6: Handling Webhooks (Raw Body Access)&lt;/h3&gt;
&lt;p&gt;Webhooks — like Stripe payment confirmations — are one of the most common real-world uses for Route Handlers. The key detail: webhook signature verification needs the &lt;strong&gt;raw, unparsed&lt;/strong&gt; request body, not JSON.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/webhook/route.ts
import { NextResponse } from &quot;next/server&quot;
import { headers } from &quot;next/headers&quot;
import Stripe from &quot;stripe&quot;
import { stripe } from &quot;@/lib/stripe&quot;

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!

export async function POST(req: Request) {
  const payload = await req.text() // raw text, NOT req.json()
  const signature = (await headers()).get(&quot;stripe-signature&quot;)

  if (!signature) {
    return NextResponse.json({ error: &quot;Missing signature&quot; }, { status: 400 })
  }

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(payload, signature, endpointSecret)
  } catch (err) {
    const message = err instanceof Error ? err.message : &quot;Unknown error&quot;
    return NextResponse.json({ error: `Webhook Error: ${message}` }, { status: 400 })
  }

  // event is now verified — safe to trust
  return NextResponse.json({ received: true })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;req.text()&lt;/code&gt; reads the body exactly as Stripe sent it. If you call &lt;code&gt;req.json()&lt;/code&gt; first, the body gets re-serialized slightly differently, and the cryptographic signature check fails — a classic gotcha that has nothing to do with your actual secret key.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/stripe-webhooks-idempotency&quot;&gt;Related: Stripe Payment Element in Production: Webhooks, Idempotency &amp;amp; Testing&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;step-7-adding-cors-support&quot;&gt;Step 7: Adding CORS Support&lt;/h3&gt;
&lt;p&gt;If your Route Handler needs to be called from a different domain (a separate frontend, a browser extension, another app), you need to explicitly allow it with CORS headers.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/public-data/route.ts
export async function GET() {
  return Response.json(
    { data: &quot;available to any origin&quot; },
    {
      headers: {
        &quot;Access-Control-Allow-Origin&quot;: &quot;*&quot;,
      },
    }
  )
}

export async function OPTIONS() {
  return new Response(null, {
    status: 204,
    headers: {
      &quot;Access-Control-Allow-Origin&quot;: &quot;*&quot;,
      &quot;Access-Control-Allow-Methods&quot;: &quot;GET, POST, OPTIONS&quot;,
      &quot;Access-Control-Allow-Headers&quot;: &quot;Content-Type, Authorization&quot;,
    },
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Setting CORS headers on &lt;code&gt;GET&lt;/code&gt;/&lt;code&gt;POST&lt;/code&gt; but forgetting &lt;code&gt;OPTIONS&lt;/code&gt;. Browsers send a &quot;preflight&quot; &lt;code&gt;OPTIONS&lt;/code&gt; request before certain cross-origin requests, and if that preflight doesn't return the right headers, the browser blocks the real request before it's ever sent — even if your &lt;code&gt;GET&lt;/code&gt; handler would have worked fine.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If you need CORS applied consistently across many routes at once, handling it in &lt;code&gt;proxy.ts&lt;/code&gt; (formerly &lt;code&gt;middleware.ts&lt;/code&gt;) instead of repeating this in every file is usually cleaner.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;route-resolution-rules-to-know&quot;&gt;Route Resolution Rules to Know&lt;/h3&gt;
&lt;p&gt;A few quick rules worth keeping in mind as your API grows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;route.ts&lt;/code&gt; file &lt;strong&gt;cannot&lt;/strong&gt; exist at the same segment as a &lt;code&gt;page.tsx&lt;/code&gt; — pick one per route.&lt;/li&gt;
&lt;li&gt;Route Handlers &lt;strong&gt;do not&lt;/strong&gt; participate in layouts or client-side navigation the way pages do. They're the lowest-level routing primitive — pure request in, response out.&lt;/li&gt;
&lt;li&gt;Each &lt;code&gt;route.ts&lt;/code&gt; takes over &lt;strong&gt;every&lt;/strong&gt; HTTP verb for that exact segment. A nested dynamic segment like &lt;code&gt;app/[user]/page.tsx&lt;/code&gt; next to &lt;code&gt;app/api/route.ts&lt;/code&gt; is perfectly valid, since they're different paths.&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Page &lt;/th&gt;&lt;th&gt;Route &lt;/th&gt;&lt;th&gt;Result&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;app/page.tsx&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;app/route.ts&lt;/code&gt; &lt;/td&gt;&lt;td&gt;✗ Conflict&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;app/page.tsx&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;app/api/route.ts&lt;/code&gt; &lt;/td&gt;&lt;td&gt;✓ Valid&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;app/[user]/page.tsx&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;app/api/route.ts&lt;/code&gt; &lt;/td&gt;&lt;td&gt;✓ Valid&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions-1&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;can-i-use-route-handlers-with-the-pages-router-&quot;&gt;Can I use Route Handlers with the Pages Router?&lt;/h4&gt;
&lt;p&gt;No — Route Handlers only work inside the &lt;code&gt;app&lt;/code&gt; directory. If your project still uses &lt;code&gt;pages/&lt;/code&gt;, you'd build API endpoints with the older &lt;code&gt;pages/api&lt;/code&gt; convention instead, which behaves differently (Express-style &lt;code&gt;req&lt;/code&gt;/&lt;code&gt;res&lt;/code&gt; rather than Web &lt;code&gt;Request&lt;/code&gt;/&lt;code&gt;Response&lt;/code&gt;).&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-route-handlers-if-i-m-already-using-server-actions-&quot;&gt;Do I need Route Handlers if I'm already using Server Actions?&lt;/h4&gt;
&lt;p&gt;Not necessarily — plenty of apps only use Server Actions for internal mutations. Reach for a Route Handler specifically when something outside your React component tree needs a callable URL: a webhook, a public API, or a request from a non-Next.js client like a mobile app.&lt;/p&gt;
&lt;h4 id=&quot;why-isn-t-my-get-route-handler-getting-cached-&quot;&gt;Why isn't my GET Route Handler getting cached?&lt;/h4&gt;
&lt;p&gt;Route Handlers are dynamic by default. You need to explicitly opt in with &lt;code&gt;export const dynamic = &quot;force-static&quot;&lt;/code&gt;, or — if you're on Cache Components — make sure the handler doesn't touch runtime APIs like &lt;code&gt;cookies()&lt;/code&gt;, &lt;code&gt;headers()&lt;/code&gt;, or an uncached database call.&lt;/p&gt;
&lt;h4 id=&quot;can-i-return-something-other-than-json-&quot;&gt;Can I return something other than JSON?&lt;/h4&gt;
&lt;p&gt;Yes. &lt;code&gt;Response&lt;/code&gt; and &lt;code&gt;NextResponse&lt;/code&gt; can return any body type — plain text, HTML, a file buffer, even a streamed response. Just set the appropriate &lt;code&gt;Content-Type&lt;/code&gt; header so the client knows how to interpret it.&lt;/p&gt;
&lt;h4 id=&quot;how-do-i-protect-a-route-handler-so-only-logged-in-users-can-call-it-&quot;&gt;How do I protect a Route Handler so only logged-in users can call it?&lt;/h4&gt;
&lt;p&gt;Check the session inside the handler itself, the same way you would inside a Server Action — never rely on &lt;code&gt;proxy.ts&lt;/code&gt;/middleware alone as your only line of defense. Most auth libraries (Clerk, Auth.js) expose a server-side helper you can call at the top of your &lt;code&gt;GET&lt;/code&gt;/&lt;code&gt;POST&lt;/code&gt; function to verify the request before doing any real work.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 If you haven't set up auth yet, see our guides on &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-clerk-authentication-in-nextjs-16&quot;&gt;Clerk&lt;/a&gt; or &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-nextauth-in-nextjs-16&quot;&gt;Auth.js (NextAuth)&lt;/a&gt; for the full setup.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-1&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;Route Handlers are the App Router's answer to &quot;I need a real, callable HTTP endpoint&quot; — whether that's a public API, a Stripe webhook, or a small internal utility route. The mental model is simple once it clicks: a &lt;code&gt;route.ts&lt;/code&gt; file, named exports per HTTP method, and the standard &lt;code&gt;Request&lt;/code&gt;/&lt;code&gt;Response&lt;/code&gt; APIs doing the actual work. Layer in dynamic segments, caching with &lt;code&gt;&quot;use cache&quot;&lt;/code&gt;, and CORS headers as your API grows, and you've got everything you need to build a production-ready backend without leaving your Next.js project.&lt;/p&gt;
&lt;p&gt;From here, a natural next step is pairing your Route Handlers with &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/zod-schema-generator&quot;&gt;Zod validation&lt;/a&gt; for safer request parsing, or wiring one up as a real webhook receiver following the Stripe pattern above.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24571/route-handlers-explained-in-next-js-16-beginners-guide</guid>
<pubDate>Thu, 13 Aug 2026 05:30:25 +0000</pubDate>
</item>
<item>
<title>Next.js 16 Routing Explained: A Complete Beginner's Guide (2026)</title>
<link>https://coderlegion.com/23721/next-js-16-routing-explained-a-complete-beginners-guide-2026</link>
<description>&lt;p&gt;If you've ever opened a Next.js project and wondered why a folder named &lt;code&gt;(marketing)&lt;/code&gt; doesn't show up in the URL, or what on earth &lt;code&gt;@modal&lt;/code&gt; means in a file path — you're not alone. Routing is one of the first things you touch in Next.js, and it's also one of the most misunderstood parts of the App Router.&lt;/p&gt;
&lt;p&gt;The good news? Once you understand the core idea — &lt;strong&gt;folders are routes&lt;/strong&gt; — everything else is just variations on that one theme.&lt;/p&gt;
&lt;p&gt;By the end of this guide, you'll understand every major routing pattern in Next.js 16: static routes, dynamic routes, nested layouts, route groups, parallel routes, intercepting routes, and catch-all segments. We'll build real examples for each one, not just theory.&lt;/p&gt;
&lt;hr&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Pattern            &lt;/th&gt;&lt;th&gt;Example                        &lt;/th&gt;&lt;th&gt;URL&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Static             &lt;/td&gt;&lt;td&gt;&lt;code&gt;about/page.tsx&lt;/code&gt;               &lt;/td&gt;&lt;td&gt;&lt;code&gt;/about&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dynamic            &lt;/td&gt;&lt;td&gt;&lt;code&gt;[slug]/page.tsx&lt;/code&gt;              &lt;/td&gt;&lt;td&gt;&lt;code&gt;/post/hello&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Catch-all          &lt;/td&gt;&lt;td&gt;&lt;code&gt;[...slug]/page.tsx&lt;/code&gt;           &lt;/td&gt;&lt;td&gt;&lt;code&gt;/docs/a/b&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Optional Catch-all &lt;/td&gt;&lt;td&gt;&lt;code&gt;[[...slug]]/page.tsx&lt;/code&gt;         &lt;/td&gt;&lt;td&gt;&lt;code&gt;/docs&lt;/code&gt; or &lt;code&gt;/docs/a&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Route Group        &lt;/td&gt;&lt;td&gt;&lt;code&gt;(marketing)/pricing/page.tsx&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;/pricing&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Parallel Route     &lt;/td&gt;&lt;td&gt;&lt;code&gt;@analytics/page.tsx&lt;/code&gt;          &lt;/td&gt;&lt;td&gt;Slot&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Intercepting Route &lt;/td&gt;&lt;td&gt;&lt;code&gt;(.)photo/[id]/page.tsx&lt;/code&gt;       &lt;/td&gt;&lt;td&gt;Modal&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-next-js-routing-feels-confusing-at-first&quot;&gt;Why Next.js Routing Feels Confusing at First&lt;/h3&gt;
&lt;p&gt;Unlike React Router or other client-side routers, Next.js doesn't use a config file or a &lt;code&gt;&amp;lt;Routes&amp;gt;&lt;/code&gt; component to define your pages. Instead, it uses your &lt;strong&gt;file system&lt;/strong&gt; as the source of truth. The folder structure inside &lt;code&gt;app/&lt;/code&gt; directly maps to the URLs your site responds to.&lt;/p&gt;
&lt;p&gt;This is powerful once it clicks, but it also means special folder names (like &lt;code&gt;[slug]&lt;/code&gt;, &lt;code&gt;(group)&lt;/code&gt;, or &lt;code&gt;@slot&lt;/code&gt;) carry real meaning. Misreading one of these conventions is usually where beginners get stuck.&lt;/p&gt;
&lt;p&gt;Let's go through them one at a time.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-basics-how-folders-become-urls&quot;&gt;The Basics: How Folders Become URLs&lt;/h3&gt;
&lt;p&gt;In the App Router, every route lives inside the &lt;code&gt;app/&lt;/code&gt; directory, and a folder only becomes a &lt;em&gt;visible&lt;/em&gt; page when it contains a &lt;code&gt;page.tsx&lt;/code&gt; file.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/about/page.tsx
export default function AboutPage() {
  return &amp;lt;h1&amp;gt;About Us&amp;lt;/h1&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This single file creates the route &lt;code&gt;/about&lt;/code&gt;. No router config, no manual registration — Next.js scans the folder tree and wires it up automatically.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; A folder without a &lt;code&gt;page.tsx&lt;/code&gt; inside it does &lt;strong&gt;not&lt;/strong&gt; create a route. It might just be there to hold layouts, components, or nested routes.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;special-files-you-ll-see-in-a-route-folder&quot;&gt;Special Files You'll See in a Route Folder&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;File &lt;/th&gt;&lt;th&gt;Purpose&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;page.tsx&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Makes the route publicly accessible (the actual UI)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;layout.tsx&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Wraps the page (and its children) with shared UI&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;loading.tsx&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Shown automatically while the page is loading&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;error.tsx&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Catches errors thrown inside that route segment&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;not-found.tsx&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Shown when &lt;code&gt;notFound()&lt;/code&gt; is called or no route matches&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;We'll use most of these as we go.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;nested-routes&quot;&gt;Nested Routes&lt;/h3&gt;
&lt;p&gt;Want &lt;code&gt;/blog/getting-started&lt;/code&gt; to be a real page? Just nest folders:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;app/
└── blog/
    ├── page.tsx              → /blog
    └── getting-started/
        └── page.tsx          → /blog/getting-started
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/getting-started/page.tsx
export default function GettingStartedPage() {
  return &amp;lt;h1&amp;gt;Getting Started With Next.js&amp;lt;/h1&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each folder level adds a segment to the URL path. There's no limit to how deep you can nest — Next.js just keeps following the folders.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;dynamic-routes&quot;&gt;Dynamic Routes&lt;/h3&gt;
&lt;p&gt;Static folders are great until you need a page per blog post, per product, or per user. That's where &lt;strong&gt;dynamic routes&lt;/strong&gt; come in, using square brackets in the folder name.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;app/
└── blog/
    └── [slug]/
        └── page.tsx          → /blog/anything-here
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/[slug]/page.tsx
interface BlogPostPageProps {
  params: Promise&amp;lt;{ slug: string }&amp;gt;;
}

export default async function BlogPostPage({ params }: BlogPostPageProps) {
  const { slug } = await params;

  return &amp;lt;h1&amp;gt;Reading post: {slug}&amp;lt;/h1&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Visiting &lt;code&gt;/blog/nextjs-routing-guide&lt;/code&gt; renders this page with &lt;code&gt;slug&lt;/code&gt; equal to &lt;code&gt;&quot;nextjs-routing-guide&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; In Next.js 16, &lt;code&gt;params&lt;/code&gt; is a &lt;strong&gt;Promise&lt;/strong&gt;, not a plain object. You must &lt;code&gt;await&lt;/code&gt; it before reading values — forgetting this is one of the most common upgrade bugs when migrating from older Next.js versions.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;generating-pages-at-build-time&quot;&gt;Generating Pages at Build Time&lt;/h4&gt;
&lt;p&gt;If you know all possible slugs ahead of time (e.g. you're reading blog posts from the file system or a CMS), you can pre-render them at build time with &lt;code&gt;generateStaticParams&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getAllPosts();

  return posts.map((post) =&amp;gt; ({
    slug: post.slug,
  }));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This tells Next.js &quot;here are all the dynamic values that exist — build a static page for each one&quot; instead of rendering them on-demand at request time.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;catch-all-and-optional-catch-all-routes&quot;&gt;Catch-All and Optional Catch-All Routes&lt;/h3&gt;
&lt;p&gt;Sometimes one dynamic segment isn't enough. Maybe you want &lt;code&gt;/docs/a&lt;/code&gt;, &lt;code&gt;/docs/a/b&lt;/code&gt;, and &lt;code&gt;/docs/a/b/c&lt;/code&gt; to all hit the same page, with the rest of the path available as an array. That's a &lt;strong&gt;catch-all route&lt;/strong&gt;, written with three dots inside the brackets.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;app/
└── docs/
    └── [...slug]/
        └── page.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/docs/[...slug]/page.tsx
interface DocsPageProps {
  params: Promise&amp;lt;{ slug: string[] }&amp;gt;;
}

export default async function DocsPage({ params }: DocsPageProps) {
  const { slug } = await params;

  // For /docs/a/b/c, slug = [&quot;a&quot;, &quot;b&quot;, &quot;c&quot;]
  return &amp;lt;h1&amp;gt;Docs path: {slug.join(&quot; / &quot;)}&amp;lt;/h1&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There's also an &lt;strong&gt;optional&lt;/strong&gt; catch-all, which uses double brackets &lt;code&gt;[[...slug]]&lt;/code&gt;. The difference: with optional catch-all, &lt;code&gt;/docs&lt;/code&gt; itself (with no extra segments) also matches the same page, instead of returning a 404.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;app/
└── docs/
    └── [[...slug]]/
        └── page.tsx          → matches /docs, /docs/a, /docs/a/b, etc.
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Catch-all routes are exactly how Clerk's &lt;code&gt;[[...sign-in]]&lt;/code&gt; pattern works under the hood — one page handles every sub-step of the auth flow.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;layouts-shared-ui-across-routes&quot;&gt;Layouts: Shared UI Across Routes&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;layout.tsx&lt;/code&gt; wraps every page inside its folder (and all nested folders below it) without losing state on navigation — things like a sidebar won't re-render or flicker when you click between pages.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/layout.tsx
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    &amp;lt;div className=&quot;flex gap-6&quot;&amp;gt;
      &amp;lt;aside className=&quot;w-48&quot;&amp;gt;Blog Sidebar&amp;lt;/aside&amp;gt;
      &amp;lt;main className=&quot;flex-1&quot;&amp;gt;{children}&amp;lt;/main&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every page under &lt;code&gt;app/blog/&lt;/code&gt; — including &lt;code&gt;[slug]/page.tsx&lt;/code&gt; — now renders inside this layout automatically. Layouts nest too: the root &lt;code&gt;app/layout.tsx&lt;/code&gt; wraps everything, and a deeper layout wraps just its own subtree.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Layouts don't re-render when you navigate between sibling pages that share them. If you need something to reset per-page (like scroll position or a &lt;code&gt;key&lt;/code&gt;-based remount), you'll need to handle that explicitly — layouts are persistent by design.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;route-groups-organizing-without-affecting-the-url&quot;&gt;Route Groups: Organizing Without Affecting the URL&lt;/h3&gt;
&lt;p&gt;Sometimes you want to organize routes into logical folders — like separating marketing pages from authenticated dashboard pages — without those folder names showing up in the URL. Wrapping a folder name in parentheses does exactly that.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;app/
├── (marketing)/
│   ├── layout.tsx        → navbar + footer
│   ├── page.tsx          → /
│   └── pricing/
│       └── page.tsx      → /pricing
│
└── (dashboard)/
    ├── layout.tsx        → sidebar + topbar
    └── settings/
        └── page.tsx      → /settings
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice that &lt;code&gt;/pricing&lt;/code&gt; doesn't become &lt;code&gt;/marketing/pricing&lt;/code&gt; — the parentheses tell Next.js &quot;this folder is for organization only, ignore it in the URL.&quot; This is exactly how you'd separate a public marketing layout from a private dashboard layout, each with completely different navigation chrome.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Route groups are also useful for giving the &lt;em&gt;same&lt;/em&gt; URL segment multiple layout options during development, or for splitting large apps into logical sections for your team without touching the live URL structure.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;parallel-routes-rendering-multiple-pages-at-once&quot;&gt;Parallel Routes: Rendering Multiple Pages at Once&lt;/h3&gt;
&lt;p&gt;This is where things get genuinely interesting. &lt;strong&gt;Parallel routes&lt;/strong&gt; let you render two or more independent pages in the &lt;em&gt;same&lt;/em&gt; layout, side by side — each with its own loading and error state. You define them with a &lt;code&gt;@&lt;/code&gt; prefix, called a &quot;slot.&quot;&lt;/p&gt;
&lt;p&gt;Imagine a dashboard that shows an analytics chart and a notifications feed at the same time, but each one loads independently:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;app/
└── dashboard/
    ├── layout.tsx
    ├── page.tsx
    ├── @analytics/
    │   └── page.tsx
    └── @notifications/
        └── page.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  notifications,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  notifications: React.ReactNode;
}) {
  return (
    &amp;lt;div className=&quot;grid grid-cols-2 gap-4&quot;&amp;gt;
      &amp;lt;div className=&quot;col-span-2&quot;&amp;gt;{children}&amp;lt;/div&amp;gt;
      &amp;lt;div&amp;gt;{analytics}&amp;lt;/div&amp;gt;
      &amp;lt;div&amp;gt;{notifications}&amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each slot (&lt;code&gt;@analytics&lt;/code&gt;, &lt;code&gt;@notifications&lt;/code&gt;) is passed into the layout as a prop, matching its folder name. Next.js renders them all in parallel — if &lt;code&gt;@notifications&lt;/code&gt; is still loading, it won't block &lt;code&gt;@analytics&lt;/code&gt; from showing up.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Slot folders need their own &lt;code&gt;page.tsx&lt;/code&gt; (or a &lt;code&gt;default.tsx&lt;/code&gt; as a fallback) — if a slot has no matching route for the current URL and no &lt;code&gt;default.tsx&lt;/code&gt;, Next.js will throw a 404 for the &lt;em&gt;entire&lt;/em&gt; layout, not just that slot.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/@notifications/default.tsx
export default function Default() {
  return null; // fallback when no route matches this slot
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;intercepting-routes-modals-that-feel-like-pages&quot;&gt;Intercepting Routes: Modals That Feel Like Pages&lt;/h3&gt;
&lt;p&gt;Ever clicked a photo on Instagram and seen it open in a modal — but if you refresh that same URL, it loads as a full standalone page instead? That's an &lt;strong&gt;intercepting route&lt;/strong&gt;, and Next.js supports this pattern natively using &lt;code&gt;(.)&lt;/code&gt;-style folder prefixes.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;app/
├── feed/
│   ├── page.tsx
│   └── @modal/
│       ├── default.tsx
│       └── (.)photo/
│           └── [id]/
│               └── page.tsx     → intercepted modal view
└── photo/
    └── [id]/
        └── page.tsx              → full page (direct visit / refresh)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The convention:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;(.)&lt;/code&gt; intercepts a route at the &lt;strong&gt;same&lt;/strong&gt; level&lt;/li&gt;
&lt;li&gt;&lt;code&gt;(..)&lt;/code&gt; intercepts one level &lt;strong&gt;above&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;(..)(..)&lt;/code&gt; intercepts two levels above&lt;/li&gt;
&lt;li&gt;&lt;code&gt;(...)&lt;/code&gt; intercepts from the &lt;strong&gt;root&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/feed/@modal/(.)photo/[id]/page.tsx
export default async function PhotoModal({
  params,
}: {
  params: Promise&amp;lt;{ id: string }&amp;gt;;
}) {
  const { id } = await params;

  return (
    &amp;lt;div className=&quot;fixed inset-0 flex items-center justify-center bg-black/50&quot;&amp;gt;
      &amp;lt;div className=&quot;bg-white p-6 rounded-xl&quot;&amp;gt;Photo {id} (modal view)&amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So when a user clicks a photo &lt;em&gt;while browsing the feed&lt;/em&gt;, Next.js intercepts the navigation and shows the modal. But if they paste that same &lt;code&gt;/photo/123&lt;/code&gt; URL directly into a new tab, Next.js skips the interception and renders the real &lt;code&gt;app/photo/[id]/page.tsx&lt;/code&gt; as a normal full page.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Intercepting routes are almost always paired with parallel routes (the &lt;code&gt;@modal&lt;/code&gt; slot) — the slot is what lets the modal render &lt;em&gt;on top of&lt;/em&gt; the feed instead of replacing it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;linking-between-routes&quot;&gt;Linking Between Routes&lt;/h3&gt;
&lt;p&gt;All of this routing is useless without navigation. Always use Next.js's &lt;code&gt;Link&lt;/code&gt; component instead of a plain &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; tag — it enables client-side transitions and automatic prefetching.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import Link from &quot;next/link&quot;;

export default function Nav() {
  return (
    &amp;lt;nav&amp;gt;
      &amp;lt;Link href=&quot;/blog&quot;&amp;gt;Blog&amp;lt;/Link&amp;gt;
      &amp;lt;Link href=&quot;/blog/nextjs-routing-guide&quot;&amp;gt;A Specific Post&amp;lt;/Link&amp;gt;
    &amp;lt;/nav&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For programmatic navigation (e.g. redirecting after a form submits), use the &lt;code&gt;useRouter&lt;/code&gt; hook in a Client Component:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&quot;use client&quot;;

import { useRouter } from &quot;next/navigation&quot;;

export default function SubmitButton() {
  const router = useRouter();

  return (
    &amp;lt;button onClick={() =&amp;gt; router.push(&quot;/dashboard&quot;)}&amp;gt;
      Go to Dashboard
    &amp;lt;/button&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions-2&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;do-route-groups-affect-performance-&quot;&gt;Do route groups affect performance?&lt;/h4&gt;
&lt;p&gt;No. Route groups are purely an organizational tool at build time — they don't add extra network requests, extra DOM nodes, or any runtime cost. They simply change how your files are organized without changing the URL.&lt;/p&gt;
&lt;h4 id=&quot;can-i-combine-dynamic-routes-with-route-groups-&quot;&gt;Can I combine dynamic routes with route groups?&lt;/h4&gt;
&lt;p&gt;Yes, and it's common. For example, &lt;code&gt;app/(dashboard)/projects/[id]/page.tsx&lt;/code&gt; gives you &lt;code&gt;/projects/123&lt;/code&gt; while keeping the dashboard layout grouping intact.&lt;/p&gt;
&lt;h4 id=&quot;what-s-the-difference-between-loading-tsx-and-a-parallel-route-s-own-loading-state-&quot;&gt;What's the difference between &lt;code&gt;loading.tsx&lt;/code&gt; and a parallel route's own loading state?&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;loading.tsx&lt;/code&gt; shows a fallback for an entire route segment while its data loads. Inside parallel routes, each slot (&lt;code&gt;@analytics&lt;/code&gt;, &lt;code&gt;@notifications&lt;/code&gt;, etc.) can have its &lt;em&gt;own&lt;/em&gt; &lt;code&gt;loading.tsx&lt;/code&gt;, so one slot can show a spinner while another already displays its content — they load independently.&lt;/p&gt;
&lt;h4 id=&quot;why-did-my-page-404-after-adding-a-parallel-route-slot-&quot;&gt;Why did my page 404 after adding a parallel route slot?&lt;/h4&gt;
&lt;p&gt;This usually means a slot (like &lt;code&gt;@modal&lt;/code&gt;) has no &lt;code&gt;page.tsx&lt;/code&gt; matching the current URL and no &lt;code&gt;default.tsx&lt;/code&gt; fallback. Add a &lt;code&gt;default.tsx&lt;/code&gt; that returns &lt;code&gt;null&lt;/code&gt; (or some neutral fallback UI) to fix it.&lt;/p&gt;
&lt;h4 id=&quot;can-intercepting-routes-work-without-parallel-routes-&quot;&gt;Can intercepting routes work without parallel routes?&lt;/h4&gt;
&lt;p&gt;Technically you can intercept into the main &lt;code&gt;children&lt;/code&gt; slot, but in practice almost every real-world use case (modals, lightboxes, previews) pairs intercepting routes with a parallel &lt;code&gt;@slot&lt;/code&gt; so the intercepted content renders &lt;em&gt;alongside&lt;/em&gt; the existing page instead of replacing it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-2&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;Next.js 16's App Router routing system might look intimidating with all its special folder syntax, but it really boils down to a handful of conventions: brackets for dynamic segments, dots for catch-alls, parentheses for invisible grouping, and &lt;code&gt;@&lt;/code&gt; for parallel slots. Once you've built each pattern once with your own hands, reading someone else's project structure stops feeling like decoding a puzzle.&lt;/p&gt;
&lt;p&gt;From here, a natural next step is wiring up authentication-protected routes using these same patterns — check out the Clerk guide below to see route groups and catch-all routes used together in a real auth flow.&lt;/p&gt;
&lt;h3 id=&quot;useful-resources&quot;&gt;Useful Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://nextjs.org/docs/app/building-your-application/routing&quot;&gt;Next.js Routing Fundamentals Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://nextjs.org/docs/app/building-your-application/routing/parallel-routes&quot;&gt;Next.js Parallel Routes Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes&quot;&gt;Next.js Intercepting Routes Docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;continue-learning-1&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-clerk-authentication-in-nextjs-16&quot;&gt;How to Add Clerk Authentication in Next.js 16 (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23721/next-js-16-routing-explained-a-complete-beginners-guide-2026</guid>
<pubDate>Wed, 29 Jul 2026 19:00:00 +0000</pubDate>
</item>
<item>
<title>How to Integrate Sanity CMS with Next.js 16 App Router</title>
<link>https://coderlegion.com/23495/how-to-integrate-sanity-cms-with-next-js-16-app-router</link>
<description>&lt;p&gt;Managing content as a developer used to mean one of two things: hardcoding everything into your codebase or wrestling with a clunky WordPress dashboard. Neither feels great when your client wants to update a blog post at 10pm on a Friday without touching a single line of code.&lt;/p&gt;
&lt;p&gt;That's exactly where &lt;strong&gt;Sanity CMS&lt;/strong&gt; shines. It's a headless CMS with a fully customizable studio, a powerful query language called GROQ, and first-class support for Next.js. And in 2026, with Next.js 16's App Router fully matured, the two work together beautifully.&lt;/p&gt;
&lt;p&gt;In this guide, you'll set up Sanity CMS inside a Next.js 16 App Router project from scratch: creating a project, defining a schema, writing GROQ queries, typing everything with TypeScript, and rendering rich text content with live updates. By the end, you'll have a working blog listing page and a dynamic post page, both powered entirely by Sanity.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-sanity-over-other-headless-cms-options-&quot;&gt;Why Sanity Over Other Headless CMS Options?&lt;/h3&gt;
&lt;p&gt;There are plenty of headless CMS choices out there — Contentful, Strapi, Payload — so why Sanity?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sanity Studio runs inside your Next.js app&lt;/strong&gt; at &lt;code&gt;/studio&lt;/code&gt;, so there's no separate admin URL to manage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GROQ&lt;/strong&gt; (Graph-Relational Object Queries) is more flexible and readable than GraphQL for content queries.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real-time collaboration&lt;/strong&gt; — multiple editors can work on content simultaneously.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Free tier is genuinely generous&lt;/strong&gt; for most personal projects and small clients.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-we-re-building&quot;&gt;What We're Building&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;An embedded Sanity Studio running at &lt;code&gt;/studio&lt;/code&gt; inside your Next.js app&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;post&lt;/code&gt; schema with title, slug, excerpt, cover image, and rich text body&lt;/li&gt;
&lt;li&gt;GROQ queries to fetch all posts and a single post by slug&lt;/li&gt;
&lt;li&gt;Fully typed Sanity responses with TypeScript&lt;/li&gt;
&lt;li&gt;A blog listing page and a dynamic post page using &lt;code&gt;generateStaticParams&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Real-time content updates using Sanity Live&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-sanity-exactly-&quot;&gt;What Is Sanity, Exactly?&lt;/h3&gt;
&lt;p&gt;Sanity is a &lt;strong&gt;headless CMS&lt;/strong&gt; — meaning the content editor (Studio) is completely decoupled from how your content gets displayed. You configure Studio with code, content gets stored remotely in what Sanity calls the Content Lake, and your Next.js app queries that content using GROQ (Sanity's own query language).&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Think of Sanity Studio as a &quot;window&quot; into your remote content. The Studio configuration lives in your repo, but the actual blog posts, images, and data live on Sanity's servers — not in your codebase.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites-1&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;Before we start, make sure you have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Node.js 20+&lt;/strong&gt; installed&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Next.js 16&lt;/strong&gt; project with the App Router (TypeScript + Tailwind)&lt;/li&gt;
&lt;li&gt;A free account at &lt;a rel=&quot;nofollow&quot; href=&quot;https://sanity.io&quot;&gt;sanity.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;h3 id=&quot;basic-familiarity-with-next-js-file-based-routing&quot;&gt;Basic familiarity with Next.js file-based routing&lt;/h3&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;step-1-create-a-sanity-project&quot;&gt;Step 1: Create a Sanity Project&lt;/h3&gt;
&lt;p&gt;First, let's create a new Sanity project from the CLI. If you don't have the Sanity CLI installed yet, run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install -g @sanity/cli
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then inside your &lt;strong&gt;Next.js project root&lt;/strong&gt;, initialize Sanity:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx sanity@latest init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The CLI will walk you through a series of prompts. Here's exactly what to choose:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;✔ Create a new project or select an existing one → Create new project
✔ Project name → whatever you like (e.g. my-blog)
✔ Select organization or create new
✔ Use the default dataset configuration? → Yes
✔ Configure Sanity MCP and agent skills for these editors? → Claude Code, GitHub Copilot CLI, VS Code
✔ Would you like to add configuration files for a Sanity project in this Next.js folder? → Yes
✔ Do you want to use TypeScript? → Yes
✔ Would you like an embedded Sanity Studio? → Yes
✔ What route do you want to use for the Studio? → /studio
✔ Select project template to use → Clean project with no predefined schema types
✔ Would you like to add the project ID and dataset to your .env.local file? → Yes
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Picking the &quot;blog&quot; template instead of &quot;Clean project&quot; gives you pre-built schema types (&lt;code&gt;post&lt;/code&gt;, &lt;code&gt;author&lt;/code&gt;, &lt;code&gt;category&lt;/code&gt;). That's fine if you want a head start, but this guide builds the &lt;code&gt;post&lt;/code&gt; schema manually so you understand exactly what each field does — so we go with the clean template.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Once it finishes, your project folder should look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;.
├── .env.local
├── sanity.cli.ts
├── sanity.config.ts
├── (...and all your Next.js files)
└── src
    ├── app
    │   └── studio
    │       └── [[...tool]]
    │           └── page.tsx
    └── sanity
        ├── lib
        │   ├── client.ts
        │   ├── image.ts
        │   └── live.ts
        ├── schemaTypes
        │   └── index.ts
        ├── env.ts
        └── schema.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;what-just-got-created-&quot;&gt;What Just Got Created?&lt;/h4&gt;
&lt;p&gt;A few of these files matter more than others:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;src/sanity/lib/client.ts&lt;/code&gt; — a configured Sanity Client instance used to query content&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src/sanity/lib/live.ts&lt;/code&gt; — exports &lt;code&gt;sanityFetch&lt;/code&gt; and &lt;code&gt;SanityLive&lt;/code&gt;, which power real-time content updates&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src/sanity/env.ts&lt;/code&gt; — reads your project ID and dataset from &lt;code&gt;.env.local&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sanity.config.ts&lt;/code&gt; — configures Studio itself: schema types, plugins, and the studio route&lt;/li&gt;
&lt;li&gt;&lt;code&gt;app/studio/[[...tool]]/page.tsx&lt;/code&gt; — the catch-all route that renders the entire Studio UI at &lt;code&gt;/studio&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Your &lt;code&gt;.env.local&lt;/code&gt; should now contain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;NEXT_PUBLIC_SANITY_PROJECT_ID=&quot;your-project-id&quot;
NEXT_PUBLIC_SANITY_DATASET=&quot;production&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; because a project ID and dataset name aren't sensitive — they're needed by the browser to talk to Sanity's API directly when previewing content.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-install-next-sanity&quot;&gt;Step 2: Install next-sanity&lt;/h3&gt;
&lt;p&gt;The CLI usually installs this for you, but if it's missing, add it manually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install next-sanity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;next-sanity&lt;/code&gt; is a toolkit of utilities built specifically for Next.js — it wraps Sanity Client with conventions for data fetching, caching, and live content updates so you're not wiring all of that together yourself.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-create-a-post-schema&quot;&gt;Step 3: Create a Post Schema&lt;/h3&gt;
&lt;p&gt;Schemas in Sanity define what content editors are &lt;em&gt;allowed&lt;/em&gt; to create in Studio — think of them as the shape of your content, similar to a database model.&lt;/p&gt;
&lt;p&gt;Create a new schema file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/sanity/schemaTypes/post.ts
import { defineField, defineType } from &quot;sanity&quot;;

export const postSchema = defineType({
  name: &quot;post&quot;,
  title: &quot;Blog Post&quot;,
  type: &quot;document&quot;,
  fields: [
    defineField({
      name: &quot;title&quot;,
      title: &quot;Title&quot;,
      type: &quot;string&quot;,
      validation: (Rule) =&amp;gt; Rule.required(),
    }),
    defineField({
      name: &quot;slug&quot;,
      title: &quot;Slug&quot;,
      type: &quot;slug&quot;,
      options: { source: &quot;title&quot; }, // auto-generates slug from title
      validation: (Rule) =&amp;gt; Rule.required(),
    }),
    defineField({
      name: &quot;excerpt&quot;,
      title: &quot;Excerpt&quot;,
      type: &quot;text&quot;,
      rows: 3,
    }),
    defineField({
      name: &quot;coverImage&quot;,
      title: &quot;Cover Image&quot;,
      type: &quot;image&quot;,
      options: { hotspot: true }, // lets editors set a focal point
    }),
    defineField({
      name: &quot;publishedAt&quot;,
      title: &quot;Published At&quot;,
      type: &quot;datetime&quot;,
    }),
    defineField({
      name: &quot;body&quot;,
      title: &quot;Body&quot;,
      type: &quot;array&quot;,
      of: [{ type: &quot;block&quot; }, { type: &quot;image&quot; }], // rich text + inline images
    }),
  ],
  orderings: [
    {
      title: &quot;Published Date, New&quot;,
      name: &quot;publishedAtDesc&quot;,
      by: [{ field: &quot;publishedAt&quot;, direction: &quot;desc&quot; }],
    },
  ],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A quick breakdown of what's happening:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;defineType&lt;/code&gt; registers a new document type — editors will see &quot;Blog Post&quot; as a creatable content type in Studio&lt;/li&gt;
&lt;li&gt;&lt;code&gt;defineField&lt;/code&gt; describes each individual field and its validation rules&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;slug&lt;/code&gt; field with &lt;code&gt;options: { source: &quot;title&quot; }&lt;/code&gt; auto-generates a URL-friendly slug from the title as the editor types&lt;/li&gt;
&lt;li&gt;&lt;code&gt;body&lt;/code&gt; uses &lt;code&gt;type: &quot;array&quot;&lt;/code&gt; with &lt;code&gt;block&lt;/code&gt; and &lt;code&gt;image&lt;/code&gt; — this is Sanity's &lt;strong&gt;Portable Text&lt;/strong&gt; format, a structured way of representing rich text that isn't tied to HTML&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; &lt;code&gt;Rule.required()&lt;/code&gt; enforces validation inside Studio itself. Editors literally cannot publish a post without a title or slug — no backend validation needed.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-register-the-schema&quot;&gt;Step 4: Register the Schema&lt;/h3&gt;
&lt;p&gt;Defining a schema isn't enough — Studio needs to know it exists. Register it in the schema index:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/sanity/schemaTypes/index.ts
import { type SchemaTypeDefinition } from &quot;sanity&quot;;
import { postSchema } from &quot;./post&quot;;

export const schema: { types: SchemaTypeDefinition[] } = {
  types: [postSchema],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting this step is the #1 reason a new schema &quot;doesn't show up&quot; in Studio. Defining the type isn't enough — it has to be added to this &lt;code&gt;types&lt;/code&gt; array.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Now start your dev server and visit &lt;code&gt;http://localhost:3000/studio&lt;/code&gt;. You should see &quot;Blog Post&quot; listed as a content type, ready to create your first post.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-write-groq-queries&quot;&gt;Step 5: Write GROQ Queries&lt;/h3&gt;
&lt;p&gt;GROQ (Graph-Relational Object Queries) is Sanity's query language, purpose-built for querying structured, nested JSON-like content.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If you've used SQL or MongoDB queries before, GROQ will feel different at first, but it's actually closer to JavaScript object/array syntax than SQL.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Create a queries file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/sanity/queries.ts
import { groq } from &quot;next-sanity&quot;;

// Fetch all posts for the blog listing page
export const allPostsQuery = groq`
  *[_type == &quot;post&quot;] | order(publishedAt desc) {
    _id,
    title,
    slug,
    excerpt,
    publishedAt,
    coverImage {
      asset-&amp;gt; { _id, url }
    }
  }
`;

// Fetch a single post by slug
export const postBySlugQuery = groq`
  *[_type == &quot;post&quot; &amp;amp;&amp;amp; slug.current == $slug][0] {
    _id,
    title,
    slug,
    excerpt,
    publishedAt,
    coverImage {
      asset-&amp;gt; { _id, url }
    },
    body
  }
`;

// Fetch all slugs for generateStaticParams
export const allPostSlugsQuery = groq`
  *[_type == &quot;post&quot;] { &quot;slug&quot;: slug.current }
`;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's what each part of the first query means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;*[_type == &quot;post&quot;]&lt;/code&gt; — selects every document where the type equals &lt;code&gt;&quot;post&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;| order(publishedAt desc)&lt;/code&gt; — sorts results by publish date, newest first&lt;/li&gt;
&lt;li&gt;&lt;code&gt;{ ... }&lt;/code&gt; — a projection, meaning &quot;only return these fields,&quot; similar to selecting specific columns in SQL&lt;/li&gt;
&lt;li&gt;&lt;code&gt;coverImage { asset-&amp;gt; { _id, url } }&lt;/code&gt; — the &lt;code&gt;-&amp;gt;&lt;/code&gt; is a &lt;strong&gt;dereference&lt;/strong&gt;, following the image asset reference to pull its actual URL&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The second query uses &lt;code&gt;$slug&lt;/code&gt; as a parameter and &lt;code&gt;[0]&lt;/code&gt; to grab just the first matching result, since slugs are unique.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-add-typescript-types&quot;&gt;Step 6: Add TypeScript Types&lt;/h3&gt;
&lt;p&gt;Since GROQ queries return plain JSON, TypeScript has no way to infer their shape automatically. Define the types yourself so the rest of your app stays type-safe:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/sanity/types.ts
export interface SanitySlug {
  current: string;
}

export interface SanityImageAsset {
  _id: string;
  url: string;
}

export interface SanityImage {
  asset: SanityImageAsset;
}

export interface Post {
  _id: string;
  title: string;
  slug: SanitySlug;
  excerpt?: string;
  publishedAt: string;
  coverImage?: SanityImage;
  body?: unknown[]; // Portable Text blocks
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Sanity also offers a code generation tool (&lt;code&gt;sanity typegen&lt;/code&gt;) that can generate these types automatically from your schema and queries. Writing them by hand works fine for smaller schemas, but it's worth exploring once your content model grows.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-7-build-the-blog-listing-page&quot;&gt;Step 7: Build the Blog Listing Page&lt;/h3&gt;
&lt;p&gt;With queries and types ready, fetching content becomes straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/page.tsx
import { sanityFetch } from &quot;@/sanity/lib/live&quot;;
import { allPostsQuery } from &quot;@/sanity/queries&quot;;
import { Post } from &quot;@/sanity/types&quot;;
import Link from &quot;next/link&quot;;

export default async function BlogPage() {
  const { data: posts } = await sanityFetch({ query: allPostsQuery });

  return (
    &amp;lt;main className=&quot;max-w-3xl mx-auto px-4 py-12&quot;&amp;gt;
      &amp;lt;h1 className=&quot;text-4xl font-bold mb-8&quot;&amp;gt;Blog&amp;lt;/h1&amp;gt;
      &amp;lt;ul className=&quot;space-y-8&quot;&amp;gt;
        {(posts as Post[]).map((post) =&amp;gt; (
          &amp;lt;li key={post._id} className=&quot;border-b pb-8&quot;&amp;gt;
            &amp;lt;Link href={`/blog/${post.slug.current}`}&amp;gt;
              &amp;lt;h2 className=&quot;text-2xl font-semibold hover:underline&quot;&amp;gt;
                {post.title}
              &amp;lt;/h2&amp;gt;
            &amp;lt;/Link&amp;gt;
            {post.excerpt &amp;amp;&amp;amp; (
              &amp;lt;p className=&quot;mt-2 text-gray-600&quot;&amp;gt;{post.excerpt}&amp;lt;/p&amp;gt;
            )}
            {post.publishedAt &amp;amp;&amp;amp; (
              &amp;lt;time className=&quot;text-sm text-gray-400&quot;&amp;gt;
                {new Date(post.publishedAt).toLocaleDateString(&quot;en-US&quot;, {
                  year: &quot;numeric&quot;,
                  month: &quot;long&quot;,
                  day: &quot;numeric&quot;,
                })}
              &amp;lt;/time&amp;gt;
            )}
          &amp;lt;/li&amp;gt;
        ))}
      &amp;lt;/ul&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice we're using &lt;code&gt;sanityFetch&lt;/code&gt; from &lt;code&gt;@/sanity/lib/live&lt;/code&gt; instead of calling the Sanity client directly. That's intentional — &lt;code&gt;sanityFetch&lt;/code&gt; is a wrapper that ties into Next.js's caching and revalidation system, and it's also what enables the live-update behavior in the next step.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-8-enable-sanity-live-in-the-root-layout&quot;&gt;Step 8: Enable Sanity Live in the Root Layout&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;SanityLive&lt;/code&gt; is a component that subscribes to Sanity's Live Content API, so changes you publish in Studio show up on your site without a manual rebuild or redeploy.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/layout.tsx
import { SanityLive } from &quot;@/sanity/lib/live&quot;;

export default function RootLayout({
  children,
}: Readonly&amp;lt;{
  children: React.ReactNode;
}&amp;gt;) {
  return (
    &amp;lt;html
      lang=&quot;en&quot;
      className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
    &amp;gt;
      &amp;lt;body className=&quot;min-h-full flex flex-col&quot;&amp;gt;
        {children}
        &amp;lt;SanityLive /&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SanityLive&lt;/code&gt; doesn't render any visible UI — it just sits in the background and triggers revalidation whenever content changes in the Content Lake.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Placing &lt;code&gt;SanityLive&lt;/code&gt; only on specific pages instead of the root layout. Since it needs to listen for changes globally, the root layout is the correct place for it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-9-style-rich-text-with-tailwind-typography&quot;&gt;Step 9: Style Rich Text with Tailwind Typography&lt;/h3&gt;
&lt;p&gt;Portable Text renders as plain HTML elements with no default styling, so headings, paragraphs, and lists will look unstyled out of the box. The Tailwind Typography plugin fixes this in one line.&lt;/p&gt;
&lt;p&gt;Install it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @tailwindcss/typography
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the plugin to your global CSS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;/* app/globals.css */
@import &quot;tailwindcss&quot;;

@plugin &quot;@tailwindcss/typography&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You'll use the &lt;code&gt;prose&lt;/code&gt; class on any container wrapping Portable Text content in the next step.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-10-build-the-dynamic-post-page&quot;&gt;Step 10: Build the Dynamic Post Page&lt;/h3&gt;
&lt;p&gt;This is where everything comes together — fetching a single post by slug, pre-rendering it at build time, and rendering its rich text body with &lt;code&gt;PortableText&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/[slug]/page.tsx
import { sanityFetch } from &quot;@/sanity/lib/live&quot;;
import { allPostSlugsQuery, postBySlugQuery } from &quot;@/sanity/queries&quot;;
import { Post } from &quot;@/sanity/types&quot;;
import { PortableText } from &quot;next-sanity&quot;;
import Image from &quot;next/image&quot;;
import { notFound } from &quot;next/navigation&quot;;

// Tell Next.js which slugs to pre-render at build time
export async function generateStaticParams() {
  const { data: slugs } = await sanityFetch({ query: allPostSlugsQuery });
  return (slugs as { slug: string }[]).map(({ slug }) =&amp;gt; ({ slug }));
}

interface PageProps {
  params: Promise&amp;lt;{ slug: string }&amp;gt;;
}

export default async function PostPage({ params }: PageProps) {
  const { slug } = await params;
  const { data } = await sanityFetch({ query: postBySlugQuery, params: { slug } });

  if (!data) notFound(); // renders your app/not-found.tsx

  const post = data as Post;

  return (
    &amp;lt;article className=&quot;max-w-3xl mx-auto px-4 py-12&quot;&amp;gt;
      &amp;lt;h1 className=&quot;text-4xl font-bold mb-4&quot;&amp;gt;{post.title}&amp;lt;/h1&amp;gt;

      {post.publishedAt &amp;amp;&amp;amp; (
        &amp;lt;time className=&quot;text-sm text-gray-400 block mb-8&quot;&amp;gt;
          {new Date(post.publishedAt).toLocaleDateString(&quot;en-US&quot;, {
            year: &quot;numeric&quot;,
            month: &quot;long&quot;,
            day: &quot;numeric&quot;,
          })}
        &amp;lt;/time&amp;gt;
      )}

      {post.coverImage?.asset?.url &amp;amp;&amp;amp; (
        &amp;lt;div className=&quot;relative mb-8 rounded-xl overflow-hidden&quot;&amp;gt;
          &amp;lt;Image
            src={post.coverImage.asset.url}
            alt={post.title}
            className=&quot;rounded-xl&quot;
            priority
            width={1200}
            height={630}
          /&amp;gt;
        &amp;lt;/div&amp;gt;
      )}

      {Array.isArray(post.body) &amp;amp;&amp;amp; (
        &amp;lt;div className=&quot;prose prose-lg max-w-none&quot;&amp;gt;
          &amp;lt;PortableText value={post.body} /&amp;gt;
        &amp;lt;/div&amp;gt;
      )}
    &amp;lt;/article&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A couple of things worth calling out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;generateStaticParams&lt;/code&gt; runs at build time and tells Next.js exactly which &lt;code&gt;/blog/[slug]&lt;/code&gt; pages to statically generate, rather than rendering every post on demand&lt;/li&gt;
&lt;li&gt;&lt;code&gt;params&lt;/code&gt; is passed as a &lt;code&gt;Promise&lt;/code&gt; — this matches Next.js 16's async params convention, which is why &lt;code&gt;postBySlugQuery&lt;/code&gt; accepts it directly inside &lt;code&gt;sanityFetch&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;notFound()&lt;/code&gt; triggers your custom &lt;code&gt;not-found.tsx&lt;/code&gt; page if no post matches the slug, instead of crashing with an unhandled error&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting &lt;code&gt;Array.isArray(post.body)&lt;/code&gt; before rendering &lt;code&gt;PortableText&lt;/code&gt;. If &lt;code&gt;body&lt;/code&gt; is ever &lt;code&gt;undefined&lt;/code&gt; (e.g. an editor saved a post without adding body content), &lt;code&gt;PortableText&lt;/code&gt; will throw instead of failing gracefully.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions-3&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;why-groq-instead-of-a-rest-or-graphql-api-&quot;&gt;Why GROQ instead of a REST or GraphQL API?&lt;/h4&gt;
&lt;p&gt;GROQ is built specifically for querying deeply nested, structured content in a single request — including resolving references like images — without the boilerplate of a separate GraphQL schema or multiple REST calls.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-to-deploy-sanity-studio-separately-&quot;&gt;Do I need to deploy Sanity Studio separately?&lt;/h4&gt;
&lt;p&gt;No. Since Studio is embedded inside your Next.js app at &lt;code&gt;/studio&lt;/code&gt;, it deploys automatically whenever your Next.js app deploys — no separate hosting or build step required.&lt;/p&gt;
&lt;h4 id=&quot;how-do-i-render-images-from-sanity-with-next-js-image-optimization-&quot;&gt;How do I render images from Sanity with Next.js Image optimization?&lt;/h4&gt;
&lt;p&gt;Use &lt;code&gt;@sanity/image-url&lt;/code&gt; to build the image URL, then pass it to Next.js &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt;. Add &lt;code&gt;cdn.sanity.io&lt;/code&gt; to the &lt;code&gt;images.remotePatterns&lt;/code&gt; array in &lt;code&gt;next.config.ts&lt;/code&gt; so Next.js is allowed to optimize it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// next.config.ts
images: {
  remotePatterns: [{ protocol: &quot;https&quot;, hostname: &quot;cdn.sanity.io&quot; }],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;is-sanitylive-safe-to-use-in-production-&quot;&gt;Is SanityLive safe to use in production?&lt;/h4&gt;
&lt;p&gt;Yes. It's designed for production use and is the recommended way to get real-time content updates without manually managing webhooks or polling.&lt;/p&gt;
&lt;h4 id=&quot;is-the-sanity-studio-accessible-to-the-public-&quot;&gt;Is the Sanity Studio accessible to the public?&lt;/h4&gt;
&lt;p&gt;By default, yes — anyone who visits &lt;code&gt;/studio&lt;/code&gt; can see it (though they can't edit without a Sanity account with the right permissions). To fully restrict it, wrap the studio route in a middleware auth check or only run it in dev using an environment variable guard.&lt;/p&gt;
&lt;h4 id=&quot;how-is-this-different-from-my-current-mdx-based-blog-&quot;&gt;How is this different from my current MDX-based blog?&lt;/h4&gt;
&lt;p&gt;With MDX, content lives as files in your repo — every edit means a commit and redeploy. With Sanity, content lives in the cloud and can be edited through a UI, published instantly, and even handed off to non-technical writers without giving them repo access.&lt;/p&gt;
&lt;h4 id=&quot;can-i-add-more-content-types-later-like-authors-or-categories-&quot;&gt;Can I add more content types later, like authors or categories?&lt;/h4&gt;
&lt;p&gt;Yes. Create a new schema file the same way you created &lt;code&gt;post.ts&lt;/code&gt;, then register it in the &lt;code&gt;types&lt;/code&gt; array inside &lt;code&gt;schemaTypes/index.ts&lt;/code&gt;. Studio will pick it up immediately.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;You now have a fully working Sanity CMS integration inside a Next.js 16 application — complete with an embedded Studio, a typed content model, GROQ-powered queries, and live content updates. From here, you can expand your schema with authors, categories, or custom block content, add Visual Editing for inline previews, or wire up on-demand revalidation through Sanity's webhooks.&lt;/p&gt;
&lt;p&gt;This setup scales well beyond a simple blog — the same pattern works for landing pages, product catalogs, or any structured content your app needs to manage outside the codebase.&lt;/p&gt;
&lt;h3 id=&quot;useful-resources-1&quot;&gt;Useful Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.sanity.io/learn/course/content-driven-web-application-foundations/create-a-new-sanity-project&quot;&gt;Sanity: Create a New Sanity Project&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.sanity.io/learn/course/content-driven-web-application-foundations/the-next-sanity-toolkit&quot;&gt;Sanity: The next-sanity Toolkit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.sanity.io/docs/groq&quot;&gt;Sanity GROQ Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.portabletext.org/&quot;&gt;Portable Text Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;continue-learning-2&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;p&gt;If you'd like to keep building on this foundation, check out these related guides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-supabase-todo-app&quot;&gt;Build a Todo App with Next.js 16 and Supabase (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;📦 &lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Muhammad-Ali-sma/Sanity-Integration-in-Next.js-16&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23495/how-to-integrate-sanity-cms-with-next-js-16-app-router</guid>
<pubDate>Sun, 26 Jul 2026 11:37:52 +0000</pubDate>
</item>
</channel>
</rss>