Next.js Expert
Specialized expertise in Next.js development, providing deep guidance on the App Router architecture, Server and Client Components, data fetching with caching and revalidation, middleware, route handlers, authentication, image and font optimization, streaming, and production deployment strategies.
When to Use This Skill
Use this skill for:
- Scaffolding new Next.js applications with App Router
- Migrating from Pages Router to App Router
- Designing layouts, loading states, and error handling
- Implementing Server Components and Client Components correctly
- Building data fetching pipelines with caching and revalidation
- Writing server actions for form mutations
- Creating middleware for auth, redirects, and request modification
- Configuring ISR, streaming, and parallel/intercepting routes
- Deploying to Vercel, self-hosted Node.js, or Docker
- Optimizing images, fonts, metadata, and Core Web Vitals
Trigger phrases: "nextjs", "next.js", "app router", "server component", "server action", "next middleware", "next deployment", "next.js routing", "pages to app router", "next.js caching", "ISR", "next image"
What This Skill Does
Provides Next.js expertise including:
- App Router: File-based routing, layouts, loading/error UI, parallel and intercepting routes
- Server Components: Data fetching, streaming, composition with Client Components
- Data Fetching: fetch caching, server actions, revalidatePath, revalidateTag
- Middleware: Request/response modification, authentication, redirects
- Route Handlers: API routes in the App Router, streaming responses
- Performance: Image optimization, font optimization, metadata, Core Web Vitals
- Authentication: Auth patterns with middleware, session management
- Deployment: Vercel, standalone Node.js output, Docker, static export
Instructions
Step 1: Structure the App Router
Standard App Router file structure:
app/
layout.tsx # Root layout (required)
page.tsx # Home page (/)
loading.tsx # Root loading UI
error.tsx # Root error UI
not-found.tsx # 404 page
global-error.tsx # Global error boundary (wraps root layout)
dashboard/
layout.tsx # Dashboard layout (nested)
page.tsx # /dashboard
loading.tsx # Dashboard loading state
settings/
page.tsx # /dashboard/settings
blog/
page.tsx # /blog (list)
[slug]/
page.tsx # /blog/:slug (dynamic segment)
opengraph-image.tsx # Dynamic OG image generation
api/
route.ts # /api (route handler)
users/
route.ts # /api/users
[id]/
route.ts # /api/users/:id
(marketing)/ # Route group (no URL segment)
about/
page.tsx # /about
contact/
page.tsx # /contact
Root layout with metadata:
// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], display: "swap" });
export const metadata: Metadata = {
title: {
default: "My App",
template: "%s | My App", // Child pages: "About | My App"
},
description: "Production Next.js application",
metadataBase: new URL("https://example.com"),
openGraph: {
type: "website",
locale: "en_US",
siteName: "My App",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.className}>
<body>
<header>
<nav>{/* Navigation */}</nav>
</header>
<main>{children}</main>
</body>
</html>
);
}
Loading and error boundaries:
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="animate-pulse">
<div className="h-8 w-48 bg-gray-200 rounded mb-4" />
<div className="h-64 bg-gray-200 rounded" />
</div>
);
}
// app/dashboard/error.tsx
"use client"; // Error boundaries must be Client Components
import { useEffect } from "react";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log to error reporting service
console.error("Dashboard error:", error);
}, [error]);
return (
<div role="alert">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button again</button>
</div>
);
}
Step 2: Server Components vs Client Components
Decision rule: Components are Server Components by default. Add "use client" only when you need browser APIs, event handlers, useState, or useEffect.
// app/dashboard/page.tsx (Server Component - no directive needed)
import { Suspense } from "react";
import { RevenueChart } from "./revenue-chart"; // Client Component
import { LatestInvoices } from "./latest-invoices"; // Server Component
// Data fetching happens directly in the Server Component
async function getStats() {
const res = await fetch("https://api.example.com/stats", {
next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
});
if (!res.ok) throw new Error("Failed to fetch stats");
return res.json() as Promise<Stats>;
}
export default async function DashboardPage() {
const stats = await getStats();
return (
<div>
<h1>Dashboard</h1>
<StatsCards stats={stats} />
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<InvoiceSkeleton />}>
<LatestInvoices />
</Suspense>
</div>
);
}
// app/dashboard/revenue-chart.tsx (Client Component)
"use client";
import { useState } from "react";
import { BarChart, Bar, XAxis, YAxis } from "recharts";
export function RevenueChart() {
const [period, setPeriod] = useState<"week" | "month">("month");
return (
<div>
<select value={period} => setPeriod(e.target.value as any)}>
<option value="week">Weekly</option>
<option value="month">Monthly</option>
</select>
<BarChart width={600} height={300} data={[]}>
<XAxis dataKey="name" />
<YAxis />
<Bar dataKey="revenue" fill="#4f46e5" />
</BarChart>
</div>
);
}
Composition boundary: pass Server Component output as children to Client Components.
// Client Component that wraps server-rendered content
"use client";
import { useState } from "react";
export function Collapsible({ title, children }: {
title: string;
children: React.ReactNode; // Server-rendered content passed as children
}) {
const [open, setOpen] = useState(false);
return (
<div>
<button => setOpen(!open)}>{title}</button>
{open && <div>{children}</div>}
</div>
);
}
// Server Component usage
import { Collapsible } from "./collapsible";
export default async function Page() {
const data = await fetchData(); // Runs on server
return (
<Collapsible title="Details">
{/* This content is server-rendered, then hydrated inside the client wrapper */}
<DataTable rows={data} />
</Collapsible>
);
}
Step 3: Data Fetching and Caching
fetch with caching strategies:
// Cache indefinitely (static data)
const staticData = await fetch("https://api.example.com/config", {
cache: "force-cache",
});
// Revalidate on a time interval (ISR)
const revalidatedData = await fetch("https://api.example.com/products", {
next: { revalidate: 3600 }, // Revalidate every hour
});
// No caching (always fresh)
const freshData = await fetch("https://api.example.com/stock", {
cache: "no-store",
});
// Tag-based revalidation
const taggedData = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] }, // Revalidate with revalidateTag("posts")
});
Server Actions for mutations:
// app/actions.ts
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
});
export async function createPost(formData: FormData) {
// Validate
const parsed = CreatePostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
// Persist
const post = await db.post.create({ data: parsed.data });
// Revalidate cached data
revalidateTag("posts");
revalidatePath("/blog");
// Redirect to the new post
redirect(`/blog/${post.slug}`);
}
// app/blog/new/page.tsx
import { createPost } from "../actions";
export default function NewPostPage() {
return (
<form action={createPost}>
<label htmlFor="title">Title</label>
<input id="title" name="title" required />
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required />
<button type="submit">Publish</button>
</form>
);
}
Step 4: Middleware
// middleware.ts (project root)
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. Authentication check
const token = request.cookies.get("session")?.value;
const protectedPaths = ["/dashboard", "/settings", "/admin"];
const isProtected = protectedPaths.some((p) => pathname.startsWith(p));
if (isProtected && !token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
// 2. Role-based access control
if (pathname.startsWith("/admin")) {
const role = request.cookies.get("role")?.value;
if (role !== "admin") {
return NextResponse.redirect(new URL("/unauthorized", request.url));
}
}
// 3. Add custom headers
const response = NextResponse.next();
response.headers.set("x-request-id", crypto.randomUUID());
// 4. Geolocation-based redirect (Vercel Edge)
const country = request.geo?.country;
if (pathname === "/" && country === "DE") {
return NextResponse.redirect(new URL("/de", request.url));
}
return response;
}
export const config = {
// Run middleware on all routes except static files and API
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/).*)"],
};
Step 5: Route Handlers
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get("page") ?? "1", 10);
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
const users = await db.user.findMany({
skip: (page - 1) * limit,
take: limit,
});
return NextResponse.json({ data: users, page, limit });
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const user = await db.user.create({ data: body });
return NextResponse.json(user, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: "Failed to create user" },
{ status: 400 }
);
}
}
// Streaming response
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ i })}\n\n`));
await new Promise((r) => setTimeout(r, 500));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
Step 6: Parallel and Intercepting Routes
Parallel routes (render multiple pages in the same layout simultaneously):
app/
dashboard/
layout.tsx
page.tsx
@analytics/
page.tsx # Rendered in parallel
loading.tsx
@notifications/
page.tsx # Rendered in parallel
loading.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
notifications,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
notifications: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12 gap-4">
<main className="col-span-8">{children}</main>
<aside className="col-span-4">
{analytics}
{notifications}
</aside>
</div>
);
}
Intercepting routes (show a modal on soft navigation, full page on hard navigation):
app/
feed/
page.tsx # Feed with photo links
(.)photo/[id]/
page.tsx # Intercepted: shows modal
photo/[id]/
page.tsx # Direct URL: shows full page
// app/feed/(.)photo/[id]/page.tsx
import { Modal } from "@/components/modal";
import { PhotoDetail } from "@/components/photo-detail";
export default async function InterceptedPhoto({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<Modal>
<PhotoDetail id={id} />
</Modal>
);
}
Step 7: Image and Font Optimization
import Image from "next/image";
import { Inter, Roboto_Mono } from "next/font/google";
import localFont from "next/font/local";
// Google fonts (automatic subsetting, self-hosting, zero layout shift)
const inter = Inter({ subsets: ["latin"], display: "swap" });
const robotoMono = Roboto_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
});
// Local font
const customFont = localFont({
src: "./fonts/CustomFont.woff2",
display: "swap",
variable: "--font-custom",
});
// Optimized image usage
function HeroImage() {
return (
<Image
src="/hero.jpg"
alt="Hero banner showing product overview"
width={1200}
height={600}
priority // Preload for LCP images
sizes="(max-width: 768px) 100vw, 1200px"
className="rounded-lg"
/>
);
}
// Remote images require domain allowlist in next.config
function Avatar({ user }: { user: { name: string; avatar: string } }) {
return (
<Image
src={user.avatar}
alt={`${user.name}'s avatar`}
width={48}
height={48}
className="rounded-full"
/>
);
}
Step 8: Deployment Configurations
Vercel (recommended):
// vercel.json (optional overrides)
{
"framework": "nextjs",
"regions": ["iad1"],
"crons": [
{
"path": "/api/cron/cleanup",
"schedule": "0 3 * * *"
}
]
}
Docker (self-hosted):
# Dockerfile
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
// next.config.ts (enable standalone output for Docker)
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
remotePatterns: [
{ protocol: "https", hostname: "cdn.example.com" },
],
},
experimental: {
typedRoutes: true, // Type-safe Link hrefs
},
};
export default nextConfig;
Step 9: Authentication Pattern
// lib/auth.ts
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { jwtVerify, type JWTPayload } from "jose";
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
export interface SessionPayload extends JWTPayload {
userId: string;
role: "user" | "admin";
}
export async function getSession(): Promise<SessionPayload | null> {
const cookieStore = await cookies();
const token = cookieStore.get("session")?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as SessionPayload;
} catch {
return null;
}
}
export async function requireAuth(): Promise<SessionPayload> {
const session = await getSession();
if (!session) redirect("/login");
return session;
}
export async function requireAdmin(): Promise<SessionPayload> {
const session = await requireAuth();
if (session.role !== "admin") redirect("/unauthorized");
return session;
}
// Usage in a Server Component
export default async function AdminPage() {
const session = await requireAdmin();
return <h1>Welcome, admin {session.userId}</h1>;
}
Best Practices
- Default to Server Components: only add
"use client"when you genuinely need browser APIs or React state/effects - Colocate data fetching with the component that uses it: avoid lifting fetches to the layout unless the data is shared
- Use Suspense boundaries strategically: wrap slow data fetches so the rest of the page streams immediately
- Prefer server actions over API routes for form mutations: they integrate with revalidation and provide progressive enhancement
- Set revalidation times based on data freshness requirements: static config rarely changes, product prices change hourly, stock levels need no-store
- Use route groups for layout organization:
(marketing),(dashboard)keep URLs clean while sharing layouts - Always provide image dimensions or use
fill: prevents cumulative layout shift - Use
loading.tsxat every route segment: gives instant loading feedback via streaming
Common Patterns
Pattern 1: Optimistic Mutation with Server Action
"use client";
import { useOptimistic, useRef } from "react";
import { addComment } from "./actions";
export function CommentForm({ comments }: { comments: Comment[] }) {
const formRef = useRef<HTMLFormElement>(null);
const [optimisticComments, addOptimistic] = useOptimistic(
comments,
(current, newComment: string) => [
...current,
{ id: "temp", text: newComment, pending: true },
]
);
async function handleSubmit(formData: FormData) {
const text = formData.get("text") as string;
addOptimistic(text);
formRef.current?.reset();
await addComment(formData);
}
return (
<>
<ul>
{optimisticComments.map((c) => (
<li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
{c.text}
</li>
))}
</ul>
<form ref={formRef} action={handleSubmit}>
<input name="text" required />
<button type="submit">Add Comment</button>
</form>
</>
);
}
Pattern 2: Streaming with Suspense Boundaries
// app/products/page.tsx
import { Suspense } from "react";
export default function ProductsPage() {
return (
<div>
<h1>Products</h1>
{/* Featured loads fast */}
<Suspense fallback={<FeaturedSkeleton />}>
<FeaturedProducts />
</Suspense>
{/* Reviews are slower, streamed in later */}
<Suspense fallback={<ReviewsSkeleton />}>
<RecentReviews />
</Suspense>
</div>
);
}
async function FeaturedProducts() {
const products = await fetch("https://api.example.com/featured", {
next: { revalidate: 300 },
}).then((r) => r.json());
return (
<ul>
{products.map((p: Product) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
async function RecentReviews() {
// This fetch is slow, but the page renders immediately
// and streams this section when ready
const reviews = await fetch("https://api.example.com/reviews", {
cache: "no-store",
}).then((r) => r.json());
return (
<ul>
{reviews.map((r: Review) => (
<li key={r.id}>{r.text} - {r.rating}/5</li>
))}
</ul>
);
}
Quality Checklist
- Root layout defines
<html>and<body>with lang attribute - Every route segment with slow data has a
loading.tsx - Error boundaries exist at critical layout levels
- Server Components do not import client-only modules
- Client Components have
"use client"directive at the top - fetch calls specify explicit caching strategy
- Server actions validate input (Zod or similar)
- Middleware matcher excludes static assets and images
- Images use
priorityfor above-the-fold LCP images - Fonts use
display: "swap"and subsetting -
next.config.tshasoutput: "standalone"for Docker deployments - Metadata is defined in layouts and pages for SEO
- Dynamic routes use
generateStaticParamswhere appropriate
Related Skills
react-expert- React component architecture, hooks, and testing patternsjavascript-cleanup- JavaScript code quality and refactoringperformance-review- Performance analysis methodologykubernetes-expert- Container orchestration for self-hosted deploymentscicd-architect- CI/CD pipeline design for Next.js builds
Version: 1.0.0 Last Updated: March 2026
Iterative Refinement Strategy
This skill is optimized for an iterative approach:
- Execute: Perform the core steps defined above.
- Review: Critically analyze the output (coverage, quality, completeness).
- Refine: If targets aren't met, repeat the specific implementation steps with improved context.
- Loop: Continue until the definition of done is satisfied.