Skip to main content

Search

Search problems, hooks, and pages

Next.js Notes

38 of 38

Key Points

  • Folders define URL segments. A route is only publicly accessible when a page.tsx or route.ts exists in that segment.
  • page.tsx , the unique UI for a route; only its exported content is sent to the client.
  • layout.tsx , shared UI that wraps pages and persists across navigations without re-rendering.
  • loading.tsx , instant loading UI; automatically wraps page.tsx in a <Suspense> boundary.
  • error.tsx , React error boundary for a segment; must be a Client Component.
  • not-found.tsx , renders when notFound() is called or no route matches.
  • route.ts , API endpoint; exports HTTP handler functions (GET, POST, PUT, DELETE, PATCH).
app/
├── layout.tsx          → wraps every route
├── page.tsx            → /
├── loading.tsx         → loading state for /
├── error.tsx           → error boundary for /
├── blog/
│   ├── layout.tsx      → wraps /blog/*
│   ├── page.tsx        → /blog
│   └── [slug]/
│       └── page.tsx    → /blog/:slug
└── api/users/
    └── route.ts        → GET/POST /api/users

Warning

Gotcha

A folder without page.tsx or route.ts is NOT publicly accessible , you can safely colocate component files, tests, and utilities inside route folders without accidentally exposing them as routes.

Key Points

  • Root layout (app/layout.tsx) is required and must include <html> and <body> tags.
  • Layouts accept a children prop , page content is injected there.
  • Nested layouts wrap only their segment: app/dashboard/layout.tsx wraps only /dashboard/* routes.
  • Layouts do NOT re-render on same-segment navigation , scroll position and state are preserved.
  • Route groups (folderName) allow multiple layouts at the same URL level without changing the URL.
  • template.tsx re-renders on every navigation (unlike layout.tsx) , useful for per-route animations.
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div>
      <Sidebar />          {/* persists across /dashboard/* */}
      <main>{children}</main>
    </div>
  );
}

Warning

Gotcha

Layout components cannot access searchParams , they don't re-render per navigation. Read searchParams only in page.tsx or Client Components via useSearchParams.

Key Points

  • [slug] , single segment: /blog/my-post → params.slug = "my-post".
  • [...slug] , catch-all: /shop/a/b/c → params.slug = ["a","b","c"].
  • [[...slug]] , optional catch-all: also matches /shop with no segments.
  • params is a Promise in Next.js 15 , must be awaited: const { slug } = await params.
  • generateStaticParams() pre-generates specific paths at build time , those are statically rendered.
  • Paths not in generateStaticParams are dynamically rendered on first request, then optionally cached.
// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params;       // must await in Next.js 15
  const post = await getPost(slug);
  return <article>{post.title}</article>;
}

export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((p) => ({ slug: p.slug }));
}

Warning

Gotcha

In Next.js 15, params and searchParams are Promises. The most common upgrade mistake: accessing params.id directly instead of (await params).id , causes a runtime error.

Key Points

  • Route group: wrap folder name in parentheses , (marketing) is omitted from the URL.
  • Use groups to share a layout among a subset of routes without nesting their URLs.
  • Multiple root layouts: one layout.tsx per group , each group can have its own <html>/<body> for totally different UIs.
  • Private folder: prefix with _ , _components/, _lib/ are never treated as routes.
  • loading.tsx scoped to a route group applies only within that group, not the whole segment.
app/
├── (marketing)/          ← URL: omitted
│   ├── layout.tsx        ← only wraps marketing routes
│   ├── page.tsx          → /
│   └── about/page.tsx    → /about
├── (shop)/
│   ├── layout.tsx        ← only wraps shop routes
│   └── cart/page.tsx     → /cart
└── _components/          ← NOT routable
    └── Button.tsx

Key Points

  • All App Router components are Server Components by default , no directive needed.
  • 'use client' at the top of a file creates a client boundary , all imports in that file become client-side too.
  • Server Components: can await DB queries, use secrets, reduce bundle size. Cannot use hooks or browser APIs.
  • Client Components: can use useState, useEffect, event handlers, localStorage, window.
  • RSC Payload: a compact binary representation of the server tree, used by React to reconcile on the client.
  • Only NEXT_PUBLIC_ env vars are available in Client Components , others are replaced with empty string.
// Server Component , fetches data, zero bundle impact
export default async function ProductPage({ params }) {
  const { id } = await params;
  const product = await db.products.findById(id);  // server-only
  return <AddToCart productId={product.id} />;
}

// Client Component — handles interactivity
'use client';
export default function AddToCart({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);
  return (
    <button onClick={() => setAdded(true)}>
      {added ? 'Added ✓' : 'Add to Cart'}
    </button>
  );
}

Warning

Gotcha

'use client' does NOT mean client-only , Client Components still run on the server during SSR (for initial HTML). It means the component is hydrated on the client and can use browser APIs after mount.

Key Points

  • Client Components CANNOT import Server Components , that would pull server code into the client bundle.
  • Server Components CAN be passed as children/props to Client Components , rendered server-side first.
  • Pattern: <Modal> (client state) wraps <Cart> (server data fetch) as children.
  • Context providers must be Client Components , keep them as deep in the tree as possible.
  • Third-party components without 'use client': wrap in your own 'use client' wrapper before using in Server Components.

Note

Textbook Definition

When a Server Component is passed as a prop (e.g., children) to a Client Component, it is rendered on the server first and its output (RSC Payload) is injected into the Client Component's slot. The Server Component's source never enters the client bundle.
// ❌ Wrong: importing Server Component inside Client Component
'use client';
import CartContents from './CartContents'; // pulls server code into bundle!

// ✅ Correct: pass as children from a Server Component
// page.tsx (Server Component)
import Modal from './Modal';        // 'use client'
import CartContents from './Cart';  // Server Component

export default function Page() {
  return (
    <Modal>
      <CartContents />  {/* rendered on server, injected as RSC payload */}
    </Modal>
  );
}

Warning

Gotcha

The rule is about module imports, not JSX nesting. You can't import a Server Component inside a 'use client' file, but you CAN receive one as a children prop from a Server Component parent.

Key Points

  • Make the component async and await data inline , no useEffect, no useState for loading/data.
  • Identical fetch() calls in the same render pass are memoized (deduplicated) by React automatically.
  • fetch() is NOT cached by default in Next.js 15 , add "use cache" or next.revalidate for caching.
  • DB/ORM calls work directly , credentials never leave the server, never reach the client bundle.
  • Parallel: initiate fetches before awaiting , const [a, b] = await Promise.all([fetchA(), fetchB()]).
  • Sequential: sometimes necessary when one request depends on the result of another.
// Server Component , no hooks, no useEffect
export default async function DashboardPage() {
  // ✅ Parallel — both start at the same time
  const [user, posts] = await Promise.all([
    fetchUser(),
    fetchPosts(),
  ]);

  // ❌ Sequential waterfall (avoid unless posts depends on user)
  // const user = await fetchUser();
  // const posts = await fetchPosts();

  return <Dashboard user={user} posts={posts} />;
}

Warning

Gotcha

Awaiting fetches sequentially (const a = await fetchA(); const b = await fetchB()) creates a waterfall even if the requests are independent. Always use Promise.all for independent data needs.

Key Points

  • Static: export const metadata = { title, description, openGraph, twitter, ... }.
  • Dynamic: export async function generateMetadata({ params }) { ... } , can fetch data.
  • Metadata is merged from parent to child , child values override parent, arrays are additive.
  • title.template in a layout: "%s | Site Name" , child pages fill %s with their own title.
  • generateMetadata runs in parallel with the page render, sharing the same fetch cache.
  • robots, sitemap, opengraph-image: dedicated file conventions in addition to the object API.
// Static metadata
export const metadata = {
  title: { template: '%s | QuickRecall', default: 'QuickRecall' },
  description: 'React interview prep tool',
};

// Dynamic metadata for /blog/[slug]
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  return {
    title: post.title,
    openGraph: { images: [post.coverImage] },
  };
}

Warning

Gotcha

Never put <title> or <meta> tags in layouts manually , the Metadata API handles deduplication and correct cascade merging. Manual tags break the merge logic.

Key Points

  • A full-stack React framework: UI + routing + a backend (API routes) in one project.
  • Rendering options: SSR, SSG, ISR, and CSR , chosen per route.
  • File-based routing , the folder structure defines URLs, no manual route config.
  • Built-in optimization: automatic code splitting, next/image, next/font.
  • First-class deployment on Vercel (its creators); also runs anywhere Node does.
// Create a project
// npx create-next-app@latest my-app

// app/page.tsx — the / route (App Router)
export default function Home() {
  return <h1>Hello, Next.js</h1>;
}

Key Points

  • Rendering: React is CSR-only by default; Next.js adds SSR, SSG, and ISR.
  • Routing: React needs React Router; Next.js has built-in file-based routing.
  • SEO: Next.js pre-renders HTML (great SEO); plain React ships an empty shell.
  • Backend: Next.js has API routes; React has none (needs a separate server).
  • Optimization: automatic code splitting + image/font optimization out of the box.
// React (CSR): browser renders an empty <div id="root"> then fills it
// Next.js: server sends ready HTML, then hydrates

// Next.js gives you, with zero setup:
// - app/about/page.tsx  → /about (routing)
// - app/api/users/route.ts → backend endpoint
// - <Image>, <Link>, next/font → optimization

Warning

Gotcha

Create React App is effectively deprecated , the React team now points new apps at frameworks like Next.js. "React vs Next" is really "library vs framework".

Key Points

  • dynamic(() => import("./Heavy")) , loads the component as a separate chunk on demand.
  • Reduces the initial JS bundle; the component is fetched when first rendered.
  • ssr: false , render only on the client (for components using window/document).
  • loading: () => <Spinner /> , show a fallback while the chunk loads.
  • Built on React.lazy + Suspense, but SSR-aware.
import dynamic from 'next/dynamic';

// Code-split + client-only (e.g. a chart using window)
const Chart = dynamic(() => import('./Chart'), {
  ssr: false,
  loading: () => <p>Loading chart…</p>
});

Warning

Gotcha

In the App Router, ssr: false is only allowed in Client Components , using it in a Server Component throws. Move the dynamic import into a "use client" file.

Key Points

  • Put key-value pairs in .env.local (git-ignored) at the project root.
  • Access with process.env.MY_VAR.
  • Only variables prefixed NEXT_PUBLIC_ are inlined into the client bundle.
  • All other vars stay server-only (API routes, Server Components, getServerSideProps).
  • Files load in order: .env.local > .env.[environment] > .env.
// .env.local
DATABASE_URL=postgres://...        // server-only
NEXT_PUBLIC_API_URL=https://api.example.com  // browser-visible

// usage
const db = process.env.DATABASE_URL;            // server only
const api = process.env.NEXT_PUBLIC_API_URL;    // client + server

Warning

Gotcha

A non-prefixed secret used in a Client Component is replaced with an empty string at build , it is NOT exposed, but your code silently breaks. Prefix with NEXT_PUBLIC_ only for genuinely public values.

Key Points

  • Pages Router: pages/api/hello.js exports default handler(req, res).
  • App Router: app/api/hello/route.ts exports named functions GET, POST, PUT, DELETE, PATCH.
  • Route Handlers use the Web Request/Response APIs (Request, NextResponse).
  • Run server-side only , safe place for secrets, DB access, and third-party keys.
  • This is what makes Next.js a full-stack framework , no separate backend needed.
// App Router , app/api/users/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const users = await db.users.findAll();
  return NextResponse.json(users);
}
export async function POST(request: Request) {
  const body = await request.json();
  const user = await db.users.create(body);
  return NextResponse.json(user, { status: 201 });
}

Warning

Gotcha

Route Handlers (route.ts) and a page.tsx cannot live in the same segment , a folder is either a page or an endpoint, not both.

Key Points

  • A single middleware.ts at the project root runs before matching requests.
  • Common uses: auth gating, redirects, rewrites, A/B testing, setting headers.
  • Runs on the Edge runtime , fast, but no Node.js APIs (no fs, limited libs).
  • Use the matcher config to scope it to specific paths (skip static assets).
  • Return NextResponse.next(), .redirect(), .rewrite(), or set cookies/headers.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(req: NextRequest) {
  const token = req.cookies.get('token');
  if (!token) return NextResponse.redirect(new URL('/login', req.url));
  return NextResponse.next();
}

export const config = { matcher: ['/dashboard/:path*'] };

Warning

Gotcha

Middleware runs on EVERY matched request, including prefetches , keep it lightweight and always add a matcher so it does not run on static files and images.

Key Points

  • pages/index.js → /, pages/about.js → /about, pages/blog/[id].js → /blog/:id.
  • _app.js wraps every page , the place for global CSS, providers, and persistent layout.
  • _document.js customizes the <html>/<body> shell (rendered once on the server).
  • _error.js / 404.js / 500.js define custom error and not-found pages.
  • Data fetching uses getStaticProps / getServerSideProps / getStaticPaths (not async components).
pages/
├── _app.js          → wraps every page (global CSS, providers)
├── _document.js     → custom <html>/<body>
├── index.js         → /
├── about.js         → /about
├── blog/[id].js     → /blog/:id
└── api/hello.js     → /api/hello

Warning

Gotcha

The Pages Router has no React Server Components and weaker nested-layout support. New apps should prefer the App Router; the Pages Router remains for legacy and incremental migration.

Key Points

  • "use server" marks a function (or whole file) as a Server Action , runs only on the server.
  • Invoke via a form’s action prop: <form action={submit}> , Next.js wires the request automatically.
  • Receives FormData; can read fields, hit the DB, then revalidate/redirect.
  • Benefits: less boilerplate (no API route), secrets stay server-side, smaller client bundle.
  • Trade-offs: a server round-trip adds latency; harder to debug than client code; less instant interactivity.
  • Alternatives when unsuitable: API routes, client fetching (SWR/React Query), SSG/SSR.
// app/actions.ts
'use server';
export async function createTodo(formData: FormData) {
  const text = formData.get('text') as string;
  await db.todos.create({ text });
  revalidatePath('/todos');
}

// app/todos/page.tsx
import { createTodo } from '../actions';
export default function Page() {
  return (
    <form action={createTodo}>
      <input name="text" />
      <button type="submit">Add</button>
    </form>
  );
}

Warning

Gotcha

Server Actions are real HTTP endpoints under the hood , always validate and authorize their input. Never trust FormData just because the action "feels" internal.

Key Points

  • Replace Pages Router pages/api with app/api/*/route.ts.
  • Export named functions per method: GET, POST, PUT, PATCH, DELETE.
  • Use the Web Request and Response (or NextResponse) APIs , Response.json(data).
  • Read query params via request.nextUrl.searchParams; body via await request.json()/formData().
  • Best practices: correct HTTP methods + status codes, validate/sanitize input, handle errors, keep modular.
// app/api/users/route.ts
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const users = await getUsers();
  return Response.json(users);
}
export async function POST(request: Request) {
  const data = await request.json();
  const user = await createUser(data);
  return Response.json(user, { status: 201 });
}
export async function DELETE(request: Request) {
  const id = new URL(request.url).searchParams.get('id');
  await deleteUser(id);
  return new Response(null, { status: 204 });
}

Warning

Gotcha

A segment can have a page.tsx OR a route.ts, not both. Put API handlers under their own path (app/api/...) so they never collide with a page.
Parallel Routes (@slots)advancedapp-router
Render multiple independent pages in the same layout simultaneously using @slot folders.

Key Points

  • Define named slots with the @folder convention (e.g. @analytics, @team).
  • Each slot is received as a prop in the layout alongside children.
  • Slots render in parallel and have independent loading and error states.
  • Useful for dashboards, split views, and conditional sections.
  • Pair with default.tsx to define fallback content for unmatched slots.
// app/layout.tsx , slots arrive as props
export default function Layout({
  children, analytics, team
}: {
  children: React.ReactNode; analytics: React.ReactNode; team: React.ReactNode;
}) {
  return (
    <>
      {children}
      <section>{analytics}</section>  {/* app/@analytics/page.tsx */}
      <section>{team}</section>       {/* app/@team/page.tsx */}
    </>
  );
}

Key Points

  • Show another route (e.g. a photo) in a modal over the current page, without losing context.
  • On a hard refresh / shared link, the same URL renders the full standalone page.
  • Conventions match like relative paths: (.) same level, (..) one up, (..)(..) two up, (...) from app root.
  • Commonly combined with a parallel @modal slot.
  • Great for photo galleries, quick-view modals, and login overlays.
app/
├── feed/page.tsx
├── photo/[id]/page.tsx          ← full page (direct visit/refresh)
└── @modal/
    └── (..)photo/[id]/page.tsx  ← intercepted as a modal over /feed

Key Points

  • error.tsx is a Client Component that catches errors thrown in its segment’s rendering.
  • It receives { error, reset } , reset() retries rendering the segment.
  • not-found.tsx renders when notFound() is called or no route matches.
  • global-error.tsx catches errors in the root layout (must include its own <html>/<body>).
  • Error boundaries do NOT catch errors in event handlers or async code outside render.
// app/dashboard/error.tsx
'use client';
export default function Error({ error, reset }: {
  error: Error; reset: () => void;
}) {
  return (
    <div>
      <p>Something went wrong: {error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

// Trigger the not-found boundary
import { notFound } from 'next/navigation';
if (!post) notFound();

Warning

Gotcha

error.tsx must start with "use client" , it relies on React error-boundary behavior. A server-only error.tsx will not compile.

Key Points

  • layout.tsx: persists across navigation , state preserved, DOM not re-created, no re-render.
  • template.tsx: a NEW instance per navigation , state reset, effects re-run, DOM re-created.
  • Use template for enter/exit animations, per-route useEffect, or resetting state on navigation.
  • If both exist, the template renders inside the layout.
  • Default to layout; reach for template only when you need the remount behavior.
// app/template.tsx , re-instantiated on every navigation
export default function Template({ children }: { children: React.ReactNode }) {
  // useEffect here re-runs on each route change (unlike layout)
  return <div className="fade-in">{children}</div>;
}

Key Points

  • Context/Redux/Zustand providers must be Client Components ("use client").
  • Wrap the app once in the root layout with a client provider component.
  • Keep the provider as deep as possible so Server Components above it stay server-rendered.
  • Server state (fetched data) is better handled by Server Components / fetch caching than a global store.
  • For Redux: a "use client" StoreProvider in layout; with SSR, create a fresh store per request.
// app/providers.tsx
'use client';
import { createContext, useContext, useState } from 'react';
const Ctx = createContext(null);
export function Providers({ children }) {
  const [user, setUser] = useState(null);
  return <Ctx.Provider value={{ user, setUser }}>{children}</Ctx.Provider>;
}

// app/layout.tsx (Server Component) wraps once
import { Providers } from './providers';
// <body><Providers>{children}</Providers></body>

Warning

Gotcha

Putting "use client" providers at the very root turns the whole tree into Client Components, forfeiting RSC benefits. Wrap only the subtree that needs the state.

Key Points

  • CORS only matters when a browser on a DIFFERENT origin calls your API , same-origin calls never need it.
  • Add Access-Control-Allow-Origin (+ -Methods / -Headers) to the Response in a Route Handler.
  • Non-simple requests (custom headers, PUT/DELETE, JSON) trigger a preflight , handle the OPTIONS method and return 204.
  • For app-wide rules, set the same headers in middleware.ts or the headers() config in next.config.js.
  • Server-to-server fetches (RSC, Route Handlers calling other APIs) are not subject to CORS , it is a browser policy.
// app/api/data/route.ts
const cors = {
  'Access-Control-Allow-Origin': 'https://other.com',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type'
};

export async function OPTIONS() {
  return new Response(null, { status: 204, headers: cors });
}

export async function GET() {
  return Response.json({ ok: true }, { headers: cors });
}

Warning

Gotcha

Forgetting the OPTIONS preflight handler is the usual cause of a "blocked by CORS" error even when GET/POST already set the headers , the browser fails the preflight before your main handler ever runs.

Key Points

  • Required: src, alt, and either width+height or fill (fill needs a position:relative parent).
  • priority , loads eagerly, skips lazy loading; use only for the LCP (above-the-fold) image.
  • placeholder="blur" , shows a blurred preview while the full image loads.
  • sizes , hints the browser which srcset entry to pick for fill/responsive images.
  • External images must be allowlisted via images.remotePatterns in next.config.js.
import Image from 'next/image';

<Image src={hero} alt="Hero" priority placeholder="blur" />

<div className="relative aspect-square w-full">
  <Image src={product.src} alt={product.name} fill sizes="(max-width: 768px) 100vw, 50vw" />
</div>

// next.config.js
module.exports = {
  images: { remotePatterns: [{ protocol: 'https', hostname: 'cdn.example.com' }] }
};

Warning

Gotcha

Forgetting priority on the hero/LCP image hurts Core Web Vitals , it gets lazy-loaded like everything else, delaying Largest Contentful Paint.

Key Points

  • import { Inter } from "next/font/google" , downloaded and self-hosted at build time, no runtime request.
  • display: "swap" shows a fallback font immediately, then swaps in the web font , avoids invisible text.
  • variable: "--font-inter" exposes the font as a CSS variable for use with Tailwind/CSS.
  • Apply className/variable on <html> or <body> in the root layout so it cascades everywhere.
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter' });

export default function RootLayout({ children }) {
  return <html className={inter.variable}><body>{children}</body></html>;
}

Key Points

  • Time-based: fetch(url, { next: { revalidate: 3600 } }) , stale-while-revalidate, regenerates in the background after 1 hour.
  • On-demand: revalidatePath("/blog") or revalidateTag("posts") from a Server Action or Route Handler , instant freshness.
  • Tag-based caching (next: { tags: ["posts"] }) lets you revalidate precisely without guessing which paths are affected.
  • The visitor who triggers a stale request still gets the OLD page instantly , the NEXT visitor gets the regenerated one.
  • CMS webhook pattern: editor publishes → webhook hits /api/revalidate → revalidateTag(...) → pages update without a deploy.
// Time-based
const data = await fetch(url, { next: { revalidate: 3600, tags: ['products'] } });

// On-demand — app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
  const { tag } = await req.json();
  revalidateTag(tag);
  return Response.json({ revalidated: true });
}

Warning

Gotcha

revalidate: 0 (or reading cookies()/headers()) forces the route to render dynamically on every request , that is SSR, not ISR.

Key Points

  • Wrap a slow async Server Component in <Suspense fallback={...}> , the rest of the page renders immediately.
  • loading.tsx is an automatic, route-level Suspense boundary around page.tsx.
  • Granular Suspense (one boundary per independent section) beats one big loading spinner for perceived performance.
  • use() (React) unwraps a Promise inside a Client Component and suspends until it resolves , pair with a Server Component that starts the fetch without awaiting it.
<div>
  <Header />                                    {/* renders instantly */}
  <Suspense fallback={<StatsSkeleton />}>
    <Stats />                                   {/* async Server Component */}
  </Suspense>
  <Suspense fallback={<OrdersSkeleton />}>
    <RecentOrders />                             {/* streams independently */}
  </Suspense>
</div>

Warning

Gotcha

Suspense only helps if the slow work is isolated in its own component , awaiting everything in one parent component still blocks the whole subtree.
Builds onMiddleware

Key Points

  • export const runtime = "edge" opts a route/layout into the Edge runtime (default is "nodejs").
  • Edge: only Web APIs (fetch, Request/Response, Web Crypto) , no fs, no native addons, ~128MB memory limit.
  • Node.js: full Node APIs (fs, native modules, Prisma, bcrypt) , larger cold starts, single-region by default.
  • middleware.ts always runs at the Edge , ideal for cheap, latency-sensitive checks (auth redirects, A/B bucketing).
  • Use jose (pure-JS JWT) instead of jsonwebtoken at the Edge , the latter relies on Node crypto internals.
// app/api/geo/route.ts , Edge
export const runtime = 'edge';
export async function GET(req: Request) {
  return Response.json({ country: req.headers.get('x-vercel-ip-country') });
}

// app/api/users/route.ts — Node.js (default), can use Prisma/bcrypt
export const runtime = 'nodejs';

Key Points

  • cookies() and headers() work in Server Components, Server Actions, and Route Handlers , never in Client Components.
  • Setting cookies (cookies().set(name, value, opts)) is only allowed in Server Actions and Route Handlers, not in a Server Component render.
  • Always set httpOnly: true, secure: true (prod), and sameSite for auth cookies , prevents XSS/CSRF from reading or forging them.
  • Reading cookies() or headers() automatically makes the route dynamic (no static/ISR caching for that render).
import { cookies, headers } from 'next/headers';

// Read (Server Component)
const theme = cookies().get('theme')?.value ?? 'light';
const ua = headers().get('user-agent');

// Write (Server Action)
'use server';
export async function login(formData: FormData) {
  cookies().set('token', await issueToken(formData), {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7,
  });
}

Warning

Gotcha

Calling cookies() inside a helper that is imported but never actually used for the current request still forces the whole route dynamic , check what your shared utils touch.

Key Points

  • Central config in auth.ts exports { handlers, signIn, signOut, auth }; app/api/auth/[...nextauth]/route.ts re-exports { GET, POST } = handlers.
  • Providers: OAuth (Google/GitHub/...), Credentials (custom email+password via authorize()), Email (magic link).
  • Sessions: JWT (default, no DB) or database (needs an adapter, e.g. Prisma) for server-tracked sessions.
  • jwt() and session() callbacks customize what ends up in the token and what is exposed to session.user.
  • Server: const session = await auth() inside Server Components , no client JS needed. Client: useSession() from "next-auth/react" for reactive UI.
// auth.ts
export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [Google({ clientId, clientSecret })],
  callbacks: {
    jwt({ token, user }) { if (user) token.id = user.id; return token; },
    session({ session, token }) { session.user.id = token.id as string; return session; },
  },
});

// Server Component
const session = await auth();
if (!session) redirect('/login');

Warning

Gotcha

Prefer server-side auth() in Server Components over useSession() , useSession requires wrapping the tree in a Client Component <SessionProvider>, adding JS just to read a session that's already known on the server.

Key Points

  • Minimize "use client" boundaries , a page where only the interactive widget is client-side ships far less JS than a page wrapped entirely in "use client".
  • Prefer SSG/ISR for public content (CDN delivery) over SSR/CSR wherever data freshness allows it.
  • Dynamic-import heavy libraries (charts, rich-text editors, maps) with next/dynamic and ssr: false , 200–500KB saved for users who never trigger them.
  • Fetch independent data in parallel (Promise.all) instead of sequential awaits , avoids request waterfalls.
  • React cache() deduplicates the same server-side data function called from multiple components (e.g. layout + page both need the current user).
  • Targets: LCP < 2.5s, INP < 200ms, CLS < 0.1.
// Push interactivity to a leaf component
function LikeButton() { 'use client'; /* useState here */ }
export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);          // server
  return <div><h1>{product.title}</h1><LikeButton /></div>;
}

// Dedup server fetches across layout + page
import { cache } from 'react';
const getUser = cache(async (id: string) => db.users.findById(id));

Key Points

  • Jest + React Testing Library for components and utilities; next/jest.js provides a preconfigured Next-aware Jest setup.
  • Route Handlers can be unit-tested directly: import { GET } from "./route" and call it with a NextRequest.
  • Server Components are harder to unit test , usually better to test the underlying data functions (getProduct, getUser) in isolation instead of rendering the RSC tree.
  • Playwright/Cypress for E2E , test real flows (login, checkout) across browsers.
  • MSW (Mock Service Worker) to stub network calls in both unit and E2E layers.
// __tests__/api/users.test.ts
import { GET } from '@/app/api/users/route';
import { NextRequest } from 'next/server';

test('GET /api/users returns a list', async () => {
  const res = await GET(new NextRequest('http://localhost/api/users'));
  expect(res.status).toBe(200);
});

// e2e/login.spec.ts (Playwright)
test('user can log in', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[name=email]', 'a@b.com');
  await page.click('button[type=submit]');
  await expect(page).toHaveURL('/dashboard');
});

Warning

Gotcha

Don't try to exhaustively unit-test simple Server Components , invest that effort in E2E coverage of the critical paths and isolated tests of the data-fetching functions they call.
Builds onMetadata API

Key Points

  • app/sitemap.ts exports a default function returning { url, lastModified, changeFrequency, priority }[] , served at /sitemap.xml.
  • app/robots.ts returns { rules, sitemap } , served at /robots.txt.
  • JSON-LD: embed a <script type="application/ld+json"> with schema.org markup (Article, Product) for rich snippets , takes minutes, high SEO ROI.
  • metadataBase in the root layout must be set or relative Open Graph image URLs resolve incorrectly in production.
// app/sitemap.ts
export default async function sitemap() {
  const posts = await getPosts();
  return posts.map(p => ({ url: `https://x.com/blog/${p.slug}`, lastModified: p.updatedAt }));
}

// JSON-LD in a page
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({
  '@context': 'https://schema.org', '@type': 'Article', headline: post.title,
}) }} />

Key Points

  • Vercel: git push → auto-deploy, preview URLs per PR, global CDN, serverless functions, ISR supported out of the box.
  • Docker: set output: "standalone" in next.config.js so the build produces a minimal self-contained server.js , pair with a multi-stage Dockerfile.
  • Self-hosted Node: next build && next start , works anywhere Node runs, but ISR needs your own cache invalidation story.
  • Static export: output: "export" , pure HTML/CSS/JS, hostable anywhere (S3, GitHub Pages) but drops SSR, Route Handlers, and ISR; images.unoptimized: true is required.
// Docker
// next.config.js
module.exports = { output: 'standalone' };

// Static export
module.exports = { output: 'export', images: { unoptimized: true } };
// next build → out/ directory of static files

Key Points

  • Typical layout: apps/web, apps/docs (Next.js apps) + packages/ui, packages/db, packages/config (shared code).
  • turbo.json defines the task pipeline (build depends on ^build of dependencies) and what to cache (.next/**).
  • transpilePackages: ["@repo/ui"] in next.config.js is needed so a Next.js app can consume un-built workspace packages.
  • turbo build --filter=web only rebuilds the affected app and its dependencies , not the whole repo.
  • Remote caching shares build artifacts across team members and CI , if the inputs match, you download the output instead of rebuilding.
// apps/web/next.config.js
module.exports = { transpilePackages: ['@repo/ui', '@repo/db'] };

// usage
import { Button } from '@repo/ui';
import { db } from '@repo/db';

// turbo build --filter=web

Key Points

  • A Route Handler calls (await draftMode()).enable() , this sets a cookie, and any request carrying it skips the fetch cache, "use cache" boundaries, and ISR entirely, hitting the CMS directly for fresh data.
  • The CMS opens a preview URL like /api/draft?secret=XXX&slug=/posts/foo , the handler validates the shared secret, verifies the slug actually exists, then redirects into the real page.
  • Your page component usually needs NO special branching , the same fetch() call is automatically served fresh when the cookie is present and from cache otherwise.
  • (await draftMode()).isEnabled reads the current state , the standard use is rendering a "you are viewing a draft" banner with an exit button that calls .disable().
  • draftMode().enable()/.disable() can only be called from a Route Handler or Server Action , never from inside a "use cache" scope, since caching directives run outside the normal request lifecycle.
// app/api/draft/route.ts , entry point from the CMS
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  if (searchParams.get('secret') !== process.env.DRAFT_SECRET) {
    return new Response('Invalid token', { status: 401 });
  }
  const draft = await draftMode();
  draft.enable();
  redirect(searchParams.get('slug') ?? '/');
}

// Page fetch needs no branching , cache is bypassed automatically
const post = await fetch(`https://cms.example.com/posts/${slug}`).then((r) => r.json());

Warning

Gotcha

Wiring the exit button to a <Link> instead of a <form> is a common mistake , Next.js prefetches <Link>s by default, which can trigger the disable route before the editor actually clicks it. Use a form (GET or POST), which is never prefetched.

Key Points

  • after(callback) queues the callback to run after the response (or prerender) finishes , the user sees the page immediately, the callback runs afterward without them waiting for it.
  • Usable in Server Components (including generateMetadata), Server Actions, and Route Handlers.
  • Calling after() does NOT make a static page dynamic , on a static route, the callback simply runs at build/revalidation time instead of per-request.
  • In a Route Handler or Server Action, cookies() and headers() can be called directly inside the after() callback. In a Server Component, they CANNOT , read them during render and pass the values in via closure instead.
  • after() always runs, even if the request errored or called notFound()/redirect() , it is not conditioned on a successful response.
import { after } from 'next/server';
import { cookies, headers } from 'next/headers';
import { logUserAction } from '@/app/utils';

export async function POST(request: Request) {
  await performMutation();

  after(async () => {
    // OK here: Route Handler, not a Server Component
    const ua = (await headers()).get('user-agent');
    logUserAction({ ua });
  });

  return Response.json({ status: 'success' }); // sent immediately, logging happens after
}

Warning

Gotcha

Calling cookies()/headers() inside an after() callback that lives in a Server Component throws a runtime error , the fix is reading them before after() runs (during the component body) and closing over the values, not reading them inside the callback itself.