Next.js Notes
38 of 38
File-based Routing & Special Filesbasicrouting
App Router maps the file system to URL segments. Special files (page, layout, loading, error, route) define the UI for each segment.
Builds onWhat is Next.js?
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/usersWarning
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.
Layouts & Nested Layoutsbasicrouting
Layouts wrap child pages and stay mounted across navigations , state is preserved, the component does not re-render.
Builds onFile-based Routing & Special Files
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.
Dynamic Routesbasicrouting
Square-bracket folders create parameterized URL segments. Params are passed as a Promise prop in Next.js 15.
Builds onFile-based Routing & Special Files
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.
Route Groups & Private Foldersintermediaterouting
Route groups (folder) organize routes without affecting the URL. Private folders (_folder) are never routable.
Builds onLayouts & Nested Layouts
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.tsxServer vs Client Componentsbasiccomponents
Server Components run only on the server (zero JS shipped). Client Components ('use client') run on server for SSR and on the client.
Builds onWhat is Next.js?
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.
Server Component Composition Patternintermediatecomponents
Pass Server Components as children/props to Client Components , server logic stays out of the bundle, interactivity stays in the client.
Builds onServer vs Client Components
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.
Data Fetching in Server Componentsbasicdata
Server Components can be async , await fetch() or DB queries directly without useEffect or loading state management.
Builds onServer vs Client Components
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.
Metadata APIbasicmetadata
Export a metadata object or generateMetadata() from page.tsx or layout.tsx to set <head> tags declaratively without manual <Head> components.
Builds onFile-based Routing & Special Files
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.
What is Next.js?basicfundamentals
A React framework for production , file-based routing, SSR/SSG/ISR, API routes, and optimization built in.
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>;
}Next.js vs React (CRA)basicfundamentals
React is a client-side UI library; Next.js is a framework adding rendering, routing, and a backend on top.
Builds onWhat is Next.js?
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".
Dynamic Import (next/dynamic)intermediateoptimization
Lazy-load components for code splitting; optionally skip SSR for browser-only components.
Builds onServer vs Client Components
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.
Environment Variablesbasicfundamentals
Defined in .env files, read via process.env , only NEXT_PUBLIC_ vars reach the browser.
Builds onWhat is Next.js?
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.
API Routes & Route Handlersbasicapi
Build backend endpoints inside the project , pages/api/* (Pages Router) or app/api/*/route.ts (App Router).
Builds onFile-based Routing & Special Files
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.
Middlewareintermediateapi
Code that runs on the Edge before a request completes , for auth, redirects, rewrites, and headers.
Builds onAPI Routes & Route Handlers
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.
Pages Router & Special Filesintermediatepages-router
The original Next.js router: files in pages/ map to routes, with _app, _document, and _error special files.
Builds onFile-based Routing & Special Files
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.
Server Actions ("use server")intermediateapp-router
Async server functions callable from components and forms , handle mutations without writing an API route.
Builds onData Fetching in Server Components
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.
Route Handlers (App Router API)intermediateapp-router
app/**/route.ts exports HTTP-method functions built on the Web Request/Response APIs.
Builds onAPI Routes & Route Handlers
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.
Builds onLayouts & Nested Layouts
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 */}
</>
);
}Intercepting Routesadvancedapp-router
Load a route from elsewhere in the app while keeping the current page’s context , the classic modal pattern.
Builds onParallel Routes (@slots)
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 /feederror.tsx & not-found.tsxintermediateapp-router
Segment-level error boundaries and 404 UI , error.tsx catches thrown errors, not-found.tsx handles notFound().
Builds onFile-based Routing & Special Files
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.
template.tsx vs layout.tsxintermediateapp-router
Both wrap children, but template remounts on every navigation while layout persists.
Builds onLayouts & Nested Layouts
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>;
}Global State in the App Routerintermediateapp-router
Server Components can’t hold client state , provide global state through a Client Component provider.
Builds onServer vs Client Components
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.
Builds onRoute Handlers (App Router 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.
next/image , Image Optimizationbasicoptimization
<Image> auto-generates responsive srcsets, lazy loads, converts to WebP/AVIF, and reserves space to prevent layout shift.
Builds onWhat is Next.js?
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.
next/font , Font Optimizationbasicoptimization
Self-hosts Google/local fonts at build time , no external network request, and display:"swap" avoids layout shift.
Builds onWhat is Next.js?
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>;
}ISR , Time-based vs On-demand Revalidationintermediatedata
Static pages can be refreshed after build without a full rebuild , either on a timer or immediately via a webhook.
Builds onData Fetching in Server Components
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.
Streaming & Suspenseintermediatecomponents
Send the HTML shell immediately, then stream in each async section as its data resolves , instead of blocking on everything.
Builds onData Fetching in Server Components
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.
Edge Runtime vs Node.js Runtimeintermediateapp-router
Edge runs on a lightweight, globally-distributed Web API sandbox with near-zero cold starts; Node.js gives full server APIs.
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';cookies() & headers() (next/headers)intermediateapi
Server-only, request-scoped APIs for reading/writing cookies and inspecting request headers , reading either opts a route into dynamic rendering.
Builds onServer vs Client Components
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.
Authentication with NextAuth.js (Auth.js)intermediateapp-router
The standard Next.js auth library , OAuth providers, credentials, JWT or DB sessions, wired through a single auth.ts config.
Builds oncookies() & headers() (next/headers)
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.
Performance Optimization Checklistintermediateoptimization
Push "use client" to the leaves, stream instead of waterfall, and let SSG/ISR do the heavy lifting for public pages.
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));Testing Next.js Appsintermediatefundamentals
Unit/integration test with Jest + React Testing Library, and cover critical user flows end-to-end with Playwright.
Builds onWhat is Next.js?
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.
SEO: sitemap.ts, robots.ts & JSON-LDintermediatemetadata
File-convention APIs auto-generate sitemap.xml and robots.txt; JSON-LD structured data unlocks rich search results.
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,
}) }} />Deployment: Vercel, Docker & Static Exportintermediatefundamentals
Vercel is zero-config; Docker needs output:"standalone"; fully static sites use output:"export" (no SSR/ISR/API routes).
Builds onWhat is Next.js?
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 filesMonorepo with Turborepoadvancedfundamentals
Host multiple Next.js apps and shared packages (ui, db, config) in one repo with cached, parallelized builds.
Builds onWhat is Next.js?
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=webDraft Modeintermediatedata
Lets a CMS editor preview unpublished content by bypassing every cache layer for their request only , other visitors keep seeing the cached page.
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.
The after() Functionintermediateapi
Schedules work to run once the response has already been sent to the user , logging and analytics that shouldn’t add latency to the page.
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.