Gotchas
Common pitfalls, breaking changes, and migration notes for Next.js 15 and 16.
params and searchParams Are Async (Next.js 15+)
The biggest breaking change in Next.js 15. params and searchParams are now Promises that must be awaited.
Before (v14)
// v14: params is a plain object
export default function Page({ params }: { params: { slug: string } }) {
return <div>{params.slug}</div>;
}
After (v15+)
// v15+: params is a Promise
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return <div>{slug}</div>;
}
This applies everywhere params appear:
// Layout
export default async function Layout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// ...
}
// generateMetadata
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return { title: `Post: ${slug}` };
}
// Route handler
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
// ...
}
searchParams in page components is also async:
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>;
}) {
const { q, page } = await searchParams;
const results = await search(q, Number(page) || 1);
return <ResultsList results={results} />;
}
Tip: Next.js provides a codemod to automate this migration:
npx @next/codemod@latest next-async-request-api .
next lint Is Deprecated (Next.js 16)
next lint and the built-in ESLint configuration are deprecated in Next.js 16. The recommendation is to use standalone linting tools.
Alternatives
| Tool | Speed | Setup |
|---|---|---|
| Biome | Very fast (Rust) | npx @biomejs/biome init |
| oxlint | Very fast (Rust) | npx oxlint@latest |
| ESLint 9 (flat config) | Moderate | Manual setup |
# Biome (recommended for new projects)
npm install -D @biomejs/biome
npx @biomejs/biome init
# oxlint (fast, zero config)
npm install -D oxlint
npx oxlint
Minimum Node.js 20.9.0
Next.js 16 requires Node.js 20.9.0 or later. Node.js 18 is no longer supported.
node --version # Must be >= 20.9.0
Caching Migration from v14 to v15+
If upgrading from v14, your fetch calls that relied on automatic caching will now hit the network every request.
Audit checklist
- Search for
fetch()calls without cache options - Identify which ones should be cached
- Add explicit
next: { revalidate: N }orcache: "force-cache"
// Before (v14): cached by default - may not be obvious
const posts = await fetch("https://api.example.com/posts");
// After (v15+): add explicit caching where needed
const posts = await fetch("https://api.example.com/posts", {
next: { revalidate: 3600 },
});
Gotcha: The v14 -> v15 caching change can cause significant performance regressions if you don’t audit. A page that made 5 cached fetch calls now makes 5 uncached network requests per visit.
cookies() and headers() Are Async (Next.js 15+)
Like params, these functions now return Promises:
// Before (v14)
import { cookies, headers } from "next/headers";
const cookieStore = cookies();
const headersList = headers();
// After (v15+)
import { cookies, headers } from "next/headers";
const cookieStore = await cookies();
const headersList = await headers();
Middleware Renamed to Proxy (Next.js 16)
In Next.js 16, middleware.ts is being transitioned to proxy.ts with an expanded feature set. The old middleware.ts still works but the new naming reflects its broader capabilities.
// proxy.ts (Next.js 16+)
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// Auth check
const token = request.cookies.get("auth-token");
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};
Gotcha: If you rename to
proxy.ts, check your deployment platform supports it. Vercel supports it natively, but self-hosted setups may need updates.
”use client” Boundaries
Marking a component with "use client" makes it and all its imports client-side. This is the most common source of accidentally large client bundles.
// BAD: entire page is client-side
"use client";
export default function Page() {
const [count, setCount] = useState(0);
return (
<div>
<HeavyMarkdownRenderer content={longContent} /> {/* shipped to client! */}
<button onClick={() => setCount(c + 1)}>{count}</button>
</div>
);
}
// GOOD: only the interactive part is client-side
export default function Page() {
return (
<div>
<HeavyMarkdownRenderer content={longContent} /> {/* stays on server */}
<Counter /> {/* only this ships JS */}
</div>
);
}
// counter.tsx
"use client";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c + 1)}>{count}</button>;
}
Dynamic Route Segment Config
Route segment config options have specific valid values. Using wrong values fails silently.
// Valid segment config exports
export const dynamic = "auto" | "force-dynamic" | "error" | "force-static";
export const revalidate = false | 0 | number; // seconds
export const runtime = "nodejs" | "edge";
export const fetchCache = "auto" | "default-cache" | "only-cache" | "force-cache" | "force-no-store" | "default-no-store" | "only-no-store";
Gotcha:
export const revalidate = truedoes nothing. It must befalse(static forever) or a number (seconds between revalidation).
Image Component Changes
The <Image> component’s alt prop is now required (warns in v15, errors in v16):
// Will warn/error
<Image src="/photo.jpg" width={800} height={600} />
// Correct
<Image src="/photo.jpg" width={800} height={600} alt="A description" />
// Decorative image
<Image src="/bg.jpg" width={800} height={600} alt="" />
Server Action Size Limits
Server Actions receive form data, which has a default body size limit of 1MB. For larger payloads (file uploads), use a Route Handler or adjust the limit:
// next.config.ts
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "10mb",
},
},
};
Parallel Routes and default.tsx
If you use parallel routes (@slot), you need default.tsx files. Without them, navigating to a URL that doesn’t match the slot causes a 404.
app/
@sidebar/
page.tsx # renders at /
default.tsx # fallback for unmatched routes
@main/
page.tsx
posts/page.tsx
default.tsx # REQUIRED: what to show when /posts doesn't match @sidebar
layout.tsx
// app/@sidebar/default.tsx
export default function Default() {
return null; // or a default sidebar
}
Environment Variables
| Prefix | Available in | Example |
|---|---|---|
NEXT_PUBLIC_ | Client + Server | NEXT_PUBLIC_API_URL |
| None | Server only | DATABASE_URL, API_SECRET |
// Server Component - works
const dbUrl = process.env.DATABASE_URL;
// Client Component - undefined!
"use client";
const dbUrl = process.env.DATABASE_URL; // undefined
// Client Component - works (with prefix)
"use client";
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
Gotcha: Forgetting the
NEXT_PUBLIC_prefix is a top-3 Next.js debugging time sink. If a value isundefinedin the client, check the prefix first.