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.

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.
CORS in Route Handlersintermediateapi
Set CORS response headers (and answer the preflight OPTIONS request) when another origin calls your Next.js API.

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.