.gemini/skills/nextjs-16/SKILL.md
Build Next.js 16 applications with the correct patterns. Use this skill when creating pages, layouts, middleware (now proxy.ts), dynamic routes, or upgrading from Next.js 15. Covers breaking changes like async params/searchParams, Turbopack defaults, proxy.ts (replacing middleware.ts), and cacheComponents.
npx skillsauth add anasahmed07/doit nextjs-16Install this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Build Next.js 16 applications correctly. This skill prevents common mistakes when working with Next.js 16's breaking changes.
THIS IS THE MOST COMMON MISTAKE. In Next.js 16, params and searchParams are asynchronous.
// WRONG - Next.js 15 pattern (WILL FAIL)
export default function Page({ params }: { params: { id: string } }) {
return <div>ID: {params.id}</div>
}
// CORRECT - Next.js 16 pattern
export default async function Page({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return <div>ID: {id}</div>
}
For searchParams:
// WRONG - Next.js 15 pattern
export default function Page({
searchParams
}: {
searchParams: { query: string }
}) {
return <div>Query: {searchParams.query}</div>
}
// CORRECT - Next.js 16 pattern
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ query?: string }>
}) {
const { query } = await searchParams
return <div>Query: {query}</div>
}
In layouts:
// CORRECT - Layout with async params
export default async function Layout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return (
<div>
<nav>Current: {slug}</nav>
{children}
</div>
)
}
In generateMetadata:
// CORRECT - Async params in metadata
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const product = await getProduct(id)
return { title: product.name }
}
DO NOT CREATE middleware.ts in Next.js 16. Use proxy.ts instead.
// File: proxy.ts (NOT middleware.ts)
// Location: project root (same level as app/)
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
// WRONG: export function middleware() {}
// CORRECT:
export function proxy(request: NextRequest) {
// Check auth
const token = request.cookies.get('token')
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
// Matcher config remains the same
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
}
Key differences from middleware:
middleware.ts → proxy.tsmiddleware() → proxy()Turbopack is the default bundler. The --turbopack flag is no longer needed.
# Next.js 15
next dev --turbopack
# Next.js 16 (Turbopack is default)
next dev
Configuration moved to top-level:
// next.config.ts
// WRONG - Next.js 15 pattern
const config = {
experimental: {
turbo: {
rules: { /* ... */ }
}
}
}
// CORRECT - Next.js 16 pattern
const config = {
turbopack: {
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
},
}
The dynamicIO experimental flag is now cacheComponents:
// next.config.ts
// WRONG - Next.js 15 pattern
const config = {
experimental: {
dynamicIO: true,
}
}
// CORRECT - Next.js 16 pattern
const config = {
cacheComponents: true,
}
Parallel routes MUST have a default.js file or you'll get 404 errors during soft navigation.
app/
├── @modal/
│ ├── default.tsx ← REQUIRED
│ └── login/
│ └── page.tsx
├── layout.tsx
└── page.tsx
// app/@modal/default.tsx
export default function Default() {
return null
}
Several next/image props have changed:
// WRONG - Next.js 15 patterns
<Image
src="/photo.jpg"
layout="fill" // Removed
objectFit="cover" // Removed
objectPosition="center" // Removed
lazyBoundary="200px" // Removed
lazyRoot={ref} // Removed
/>
// CORRECT - Next.js 16 patterns
<Image
src="/photo.jpg"
fill // Use fill prop
style={{
objectFit: 'cover',
objectPosition: 'center'
}}
/>
Route handlers also need async params:
// WRONG
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
return Response.json({ id: params.id })
}
// CORRECT
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
return Response.json({ id })
}
# Create new Next.js 16 project
npx create-next-app@latest my-app
# Or upgrade existing project
npm install next@16 react@latest react-dom@latest
my-app/
├── app/
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Home page
│ ├── globals.css
│ ├── @modal/ # Parallel route
│ │ ├── default.tsx # REQUIRED default
│ │ └── login/
│ │ └── page.tsx
│ ├── dashboard/
│ │ ├── layout.tsx
│ │ └── [id]/ # Dynamic route
│ │ └── page.tsx # Uses async params
│ └── api/
│ └── tasks/
│ └── [id]/
│ └── route.ts # Uses async params
├── proxy.ts # NOT middleware.ts
├── next.config.ts # Turbopack at top-level
├── package.json
└── tsconfig.json
When using Better Auth or similar with httpOnly cookies, JavaScript cannot access the token. Create a server-side API route to forward requests with the token:
// app/api/proxy/[...path]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8000";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params;
const cookieStore = await cookies();
// Read httpOnly cookie (only accessible server-side)
const idToken = cookieStore.get("auth_token")?.value;
if (!idToken) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
// Build target URL
const targetPath = "/" + path.join("/");
const url = new URL(targetPath, BACKEND_URL);
// Forward query params
request.nextUrl.searchParams.forEach((value, key) => {
url.searchParams.set(key, value);
});
try {
const body = await request.text();
const response = await fetch(url.toString(), {
method: "POST",
headers: {
Authorization: `Bearer ${idToken}`,
"Content-Type": "application/json",
},
body: body || undefined,
});
// Handle SSE streaming responses
if (response.headers.get("content-type")?.includes("text/event-stream")) {
return new Response(response.body, {
status: response.status,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}
const data = await response.json().catch(() => null);
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("[Proxy] Error:", error);
return NextResponse.json({ error: "Proxy request failed" }, { status: 500 });
}
}
// Add GET, PUT, DELETE as needed with same pattern
Key points:
cookies() from next/headersresponse.bodycredentials: "include" on client fetch to send cookies to the proxyEvidence: web-dashboard/src/app/api/chatkit/route.ts
External web component scripts must load before React hydration. Use beforeInteractive in root layout:
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
{/* MUST be in <head> with beforeInteractive for web components */}
<Script
src="https://cdn.example.com/web-component.js"
strategy="beforeInteractive"
/>
</head>
<body>{children}</body>
</html>
);
}
Script strategies:
| Strategy | When It Loads | Use Case |
|----------|---------------|----------|
| beforeInteractive | Before hydration, in <head> | Web components, critical JS |
| afterInteractive | After page interactive | Analytics, non-critical |
| lazyOnload | During idle time | Low priority |
Evidence: web-dashboard/src/app/layout.tsx
// app/tasks/[id]/page.tsx
import { notFound } from 'next/navigation'
interface Props {
params: Promise<{ id: string }>
}
async function getTask(id: string) {
const res = await fetch(`${process.env.API_URL}/api/tasks/${id}`)
if (!res.ok) return null
return res.json()
}
export async function generateMetadata({ params }: Props) {
const { id } = await params
const task = await getTask(id)
return { title: task?.title ?? 'Task Not Found' }
}
export default async function TaskPage({ params }: Props) {
const { id } = await params
const task = await getTask(id)
if (!task) notFound()
return (
<div>
<h1>{task.title}</h1>
<p>Status: {task.status}</p>
</div>
)
}
// app/search/page.tsx
interface Props {
searchParams: Promise<{
query?: string
status?: string
page?: string
}>
}
export default async function SearchPage({ searchParams }: Props) {
const { query, status, page = '1' } = await searchParams
const results = await fetch(
`${process.env.API_URL}/api/search?` +
new URLSearchParams({
...(query && { query }),
...(status && { status }),
page,
})
).then(r => r.json())
return (
<div>
<h1>Search Results for: {query}</h1>
{results.map(r => <div key={r.id}>{r.title}</div>)}
</div>
)
}
// proxy.ts
import { NextRequest, NextResponse } from 'next/server'
const publicPaths = ['/', '/login', '/register', '/api/auth']
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
// Allow public paths
if (publicPaths.some(p => pathname.startsWith(p))) {
return NextResponse.next()
}
// Check for auth token
const token = request.cookies.get('session')?.value
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
// Add user header for API routes
const response = NextResponse.next()
response.headers.set('x-user-token', token)
return response
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico).*)',
],
}
// app/api/tasks/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
interface Props {
params: Promise<{ id: string }>
}
export async function GET(request: NextRequest, { params }: Props) {
const { id } = await params
// Fetch from backend
const res = await fetch(`${process.env.BACKEND_URL}/api/tasks/${id}`, {
headers: {
Authorization: request.headers.get('Authorization') ?? '',
},
})
if (!res.ok) {
return NextResponse.json(
{ error: 'Task not found' },
{ status: 404 }
)
}
return NextResponse.json(await res.json())
}
export async function PATCH(request: NextRequest, { params }: Props) {
const { id } = await params
const body = await request.json()
const res = await fetch(`${process.env.BACKEND_URL}/api/tasks/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: request.headers.get('Authorization') ?? '',
},
body: JSON.stringify(body),
})
return NextResponse.json(await res.json(), { status: res.status })
}
import type { NextConfig } from 'next'
const config: NextConfig = {
// Turbopack config (was experimental.turbo)
turbopack: {
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
},
// Cache components (was experimental.dynamicIO)
cacheComponents: true,
// Environment variables (public)
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
// Images
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**.example.com',
},
],
},
// Rewrites for API proxy
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${process.env.BACKEND_URL}/api/:path*`,
},
]
},
}
export default config
When upgrading from Next.js 15:
middleware.ts to proxy.tsmiddleware() function to proxy()paramssearchParamsparamsgenerateMetadata with async paramsturbo config from experimental.turbo to turbopackdynamicIO with cacheComponentsdefault.tsx to all parallel routesnext/image usage (remove layout, objectFit props)--turbopack flag from dev scripts// WRONG - Results in Promise object, not value
export default async function Page({ params }) {
return <div>ID: {params.id}</div> // Shows [object Promise]
}
// CORRECT
export default async function Page({ params }) {
const { id } = await params
return <div>ID: {id}</div>
}
// WRONG - File will be ignored
// middleware.ts
// CORRECT - Use proxy.ts
// proxy.ts
export function proxy(request) { ... }
// WRONG - 404 during soft navigation
app/@sidebar/page.tsx
// CORRECT - Include default
app/@sidebar/default.tsx
app/@sidebar/page.tsx
// WRONG
experimental: { turbo: {} }
// CORRECT
turbopack: {}
// WRONG - httpOnly cookies cannot be read from JavaScript
const token = document.cookie.split('; ')
.find(row => row.startsWith('auth_token='));
// Returns undefined even if cookie exists
// CORRECT - Use server-side API route proxy
// app/api/proxy/route.ts reads cookies via next/headers
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
// WRONG - Web component not defined when React renders
<Script src="https://cdn.example.com/component.js" strategy="afterInteractive" />
// CORRECT - Load before React hydration
<head>
<Script src="https://cdn.example.com/component.js" strategy="beforeInteractive" />
</head>
For additional documentation, use Next.js DevTools MCP:
mcp__next-devtools__nextjs_docs with action="get" and path="/docs/app/guides/upgrading/version-16"
Or Context7:
mcp__context7__get-library-docs with context7CompatibleLibraryID="/vercel/next.js" and topic="app router"
development
Use when building real-time communication systems with WebSockets or Socket.IO. Invoke for bidirectional messaging, horizontal scaling with Redis, presence tracking, room management.
development
Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
development
React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
tools
Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.