Build, maintain, and optimize production-grade full-stack web applications using the Next.js App Router (versions 14, 15, and 16+). Covers architecture, React Server Components, Client Components, data fetching, caching, Server Actions, Route Handlers, middleware, performance optimization, and TypeScript configuration.
57
72%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./skills/development-skills/nextjs-development/SKILL.mdA comprehensive guide for building high-performance, scalable web applications using the Next.js App Router architecture. Next.js combines React Server Components (RSC), streaming, hybrid rendering strategies, and integrated optimizations for modern web workloads.
pages/) to App Router (app/).'use client').fetch, 'use cache', revalidateTag, revalidatePath).app/api/**/route.ts).middleware.ts.next/image, next/font, dynamic imports).await params, await cookies()).pnpm, npm, yarn, or bun.Scaffold a project with standard flags or configure an existing repository for modern App Router usage.
# Initialize a new Next.js application with TypeScript, ESLint, and Tailwind CSS
npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"Configure tsconfig.json to enable strict mode and module resolution for Next.js:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}Maintain next.config.ts (or next.config.js) for runtime flags, remote image domains, and headers:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactStrictMode: true,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
pathname: '/**',
},
],
},
// Enable modern caching model or experimental features if using Next.js 15+
experimental: {
// dynamicIO: true, // For modern 'use cache' directive support in Next.js 15+
},
};
export default nextConfig;The app/ directory relies on nested folders to define route segments and specialized file conventions:
| File | Purpose | Execution |
|---|---|---|
layout.tsx | Shared layout across child routes; preserves state on navigation | Server by default |
page.tsx | Unique UI rendered for a specific route path | Server by default |
loading.tsx | Instant loading skeleton wrapped in React Suspense | Server/Client |
error.tsx | Route segment error boundary (catches runtime errors in page.tsx) | Must be Client ('use client') |
not-found.tsx | 404 UI rendered when notFound() is thrown or route doesn't match | Server/Client |
template.tsx | Similar to layout, but mounts a new instance on every navigation | Server by default |
default.tsx | Fallback component for parallel route slots during hard reload | Server/Client |
route.ts | API endpoint handler for HTTP methods (GET, POST, etc.) | Server only |
app/layout.tsx):Every application requires a single root layout containing <html> and <body> tags:
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
export const metadata: Metadata = {
title: {
template: '%s | Platform',
default: 'Platform Home',
},
description: 'Production Next.js application',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={inter.variable}>
<body className="min-h-screen bg-background font-sans antialiased">
<main>{children}</main>
</body>
</html>
);
}By default, all components inside the app/ directory are React Server Components (RSC).
'use client'):useState, useReducer).useEffect, useLayoutEffect).onClick, onChange, onSubmit).window, localStorage, geolocation, media queries).Push Client Components down to the leaves of the render tree. Pass Server Components as children into Client Component wrappers to avoid converting the entire tree into client code.
// components/client-wrapper.tsx
'use client';
import { useState } from 'react';
export function ModalWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
{isOpen && (
<div className="modal-backdrop">
{/* children is rendered as a Server Component on the server */}
{children}
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</div>
);
}To enforce that code never leaks into the client bundle, install and import server-only:
npm install server-only// lib/db.ts
import 'server-only';
export async function queryDatabase() {
// Safe from accidental client imports
}Next.js provides multiple rendering strategies: SSG (Static Site Generation), SSR (Server-Side Rendering), and ISR (Incremental Static Regeneration).
[!NOTE] Next.js 15+ Async API Change: In Next.js 15+, dynamic route parameters (
params), search parameters (searchParams),cookies(), andheaders()return Promises and must be awaited.
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then((res) => res.json());
return posts.map((post: { slug: string }) => ({ slug: post.slug }));
}
export default async function PostPage({ params }: PageProps) {
const { slug } = await params;
const post = await fetch(`https://api.example.com/posts/${slug}`).then((res) => {
if (!res.ok) return null;
return res.json();
});
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}// Fetch with revalidation time in seconds
const data = await fetch('https://api.example.com/items', {
next: { revalidate: 3600 }, // Revalidate at most once every hour
});// Fetch with a cache tag
const data = await fetch('https://api.example.com/products', {
next: { tags: ['products'] },
});Revalidate the tag on-demand from a Server Action or Route Handler:
// app/actions.ts
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';
export async function refreshCatalog() {
revalidateTag('products');
revalidatePath('/products');
}'use cache' (Next.js 15+ / 16+):When dynamicIO is enabled, use the 'use cache' directive at the file or function level:
// lib/cached-data.ts
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
export async function getLeaderboard() {
'use cache';
cacheLife('hours'); // preset: seconds, minutes, hours, days, max
cacheTag('leaderboard');
const res = await fetch('https://api.example.com/leaderboard');
return res.json();
}Route Handlers live in route.ts files and handle HTTP methods via explicit exports (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS).
// app/api/items/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface RouteContext {
params: Promise<{ id: string }>;
}
export async function GET(request: NextRequest, { params }: RouteContext) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const filter = searchParams.get('filter');
return NextResponse.json({ id, filter, timestamp: Date.now() }, { status: 200 });
}
export async function POST(request: NextRequest, { params }: RouteContext) {
const { id } = await params;
try {
const body = await request.json();
if (!body.title) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
}
return NextResponse.json({ success: true, id, item: body }, { status: 201 });
} catch {
return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 });
}
}Create middleware.ts in the root directory (or inside src/) to intercept requests before routing completes.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('session_token')?.value;
const pathname = request.nextUrl.pathname;
// Protect private routes
if (pathname.startsWith('/dashboard') && !token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
// Add security headers to response
const response = NextResponse.next();
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return response;
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt
*/
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
};next/image):Prevents Cumulative Layout Shift (CLS) and serves modern formats (WebP, AVIF) with responsive srcset.
import Image from 'next/image';
export function ProductHero({ imageUrl }: { imageUrl: string }) {
return (
<div className="relative aspect-video w-full overflow-hidden rounded-lg">
<Image
src={imageUrl}
alt="Featured Product"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority // Preloads LCP image
className="object-cover"
/>
</div>
);
}next/font):Zero layout shift with automatic font self-hosting and CSS variable binding:
// lib/fonts.ts
import { Inter, JetBrains_Mono } from 'next/font/google';
export const fontSans = Inter({
subsets: ['latin'],
variable: '--font-sans',
display: 'swap',
});
export const fontMono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
});Advanced routing patterns enable complex modal views, split dashboards, and conditional states.
@slot):Defined using named slots with @ prefix. Rendered simultaneously in a parent layout:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
notifications,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
notifications: React.ReactNode;
}) {
return (
<div className="dashboard-grid">
<div className="main-content">{children}</div>
<aside className="sidebar">
{analytics}
{notifications}
</aside>
</div>
);
}Note: Always create default.tsx inside each slot folder to prevent 404 errors during client-side navigation.
Allow loading a route within the current layout while sharing the URL (e.g., photo modal overlays):
(.) matches segments on the same level(..) matches segments one level above(..)(..) matches segments two levels above(...) matches segments from the root app directory// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await fetch(`https://api.example.com/posts/${slug}`).then((res) => res.json());
if (!post) {
return { title: 'Post Not Found' };
}
return {
title: post.title,
description: post.summary,
openGraph: {
title: post.title,
description: post.summary,
url: `https://example.com/blog/${slug}`,
images: [
{
url: post.ogImageUrl || '/og-default.png',
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.summary,
},
};
}app/sitemap.ts and app/robots.ts):// app/sitemap.ts
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetch('https://api.example.com/posts').then((res) => res.json());
const postEntries: MetadataRoute.Sitemap = posts.map((post: { slug: string; updatedAt: string }) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly',
priority: 0.7,
}));
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1.0,
},
...postEntries,
];
}.env.local: Local overrides (ignored by Git)..env.production: Production defaults.NEXT_PUBLIC_ are bundled into the client code.Validate environment variables at build/runtime using schema libraries:
// lib/env.ts
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NEXT_PUBLIC_API_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
});
export const env = envSchema.parse({
DATABASE_URL: process.env.DATABASE_URL,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NODE_ENV: process.env.NODE_ENV,
});'use client' only when interactive event handlers or state hooks are strictly necessary.await params, await searchParams, await cookies(), and await headers() in Next.js 15+ to prevent runtime deprecation warnings and errors.<Suspense fallback={<Skeleton />}> rather than blocking the entire route page.import 'server-only' in database, payment, or auth utility modules to prevent accidental bundle leaks to the client.width/height or use fill with an accurate sizes prop on next/image to prevent CLS.params Access in Next.js 15+: Attempting to read props.params.slug directly without await causes hydration mismatch or runtime warnings.'use client' at the top of a page or high-level layout converts all child components (and their imported modules) into client bundles.process.env.SECRET_KEY inside a Client Component renders undefined or risks bundling secrets into client assets.default.tsx in Parallel Routes: Failing to supply a default.tsx file in @slot directories causes Next.js to render 404 when refreshing or hard-navigating to unmatched sub-routes.'use client': Defining an error.tsx file without 'use client' throws a build error; error boundaries must be Client Components.layout.tsx to re-mount state during navigation. Use template.tsx if full component unmount/remount on navigation is required.Confirm proper configuration and application health with these automated gates:
Linting Inspection:
npx next lintEnsures compliance with Next.js ESLint rules (@next/next/recommended, Core Web Vitals checks).
TypeScript Compilation Check:
npx tsc --noEmitValidates end-to-end typing, route context props, and Promise resolution for Next.js 15+ request APIs.
Production Build & Route Manifest Inspection:
npx next buildInspect the output summary table:
○ (Static): Prerendered as static content.● (SSG): Prerendered using generateStaticParams.ƒ (Dynamic): Server-rendered on demand via dynamic request headers/cookies.Production Runtime Verification:
npx next startTest end-to-end navigations, streaming Suspense fallbacks, image optimization endpoints, and middleware redirection behavior.
For detailed code patterns, nested layout implementations, Server Actions, streaming, and intercepting route walkthroughs, refer to:
87555db
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.