Next.js 13 App Directory: What Changed by Next.js 16
The folder contract is unchanged, but six APIs drifted: async params, searchParams and cookies(), the proxy rename, and two changes that never warn.
The Next.js 13 app directory is the routing model where folders under app/ become URL segments and reserved filenames like page.tsx and layout.tsx attach behaviour to them. It is still the model in Next.js 16 — the folder contract never changed. What changed is the API inside those files: params, searchParams and cookies() are all async now, and middleware.ts has been renamed to proxy.ts.
So a Next.js 13 tutorial is still structurally correct and will still teach you the right mental model. It is the copy-pasteable code in it that has drifted. This post is the diff, taken from a production Next.js 16 app — 16.2.9, 27 page.tsx files, 111 product pages and 289 blog posts — rather than from a release note.
What the app directory introduced, and still means
Next.js 13 replaced pages/ with a directory where the filename is the registration. There is no route config anywhere. A folder makes a segment; a reserved file inside it gives that segment behaviour:
src/app/
page.tsx → /
pricing/page.tsx → /pricing
templates/[slug]/page.tsx → /templates/asoc-lura-admin
blog/feed.xml/route.ts → /blog/feed.xml
dashboard/layout.tsx → wraps /dashboard and everything under it
That part of every Next.js 13 tutorial is still accurate three majors later, and it is the half worth learning. If you want the full census of which reserved filenames a real app of this size actually uses — six of the nine, it turns out — that is Next.js routing. If you are deciding whether to adopt it at all, App Router vs Pages Router is the honest version of that question; the Pages Router is still not deprecated.
What follows is only the drift.
The six changes, with the Next.js 13 code beside the Next.js 16 code
| Next.js 13 wrote | Next.js 16 wants | Breaks how |
|---|---|---|
params: { slug: string } | params: Promise<{ slug: string }> | Type error at build; params.slug is undefined |
searchParams: { q?: string } | searchParams: Promise<{ q?: string }> | Same |
const c = cookies() | const c = await cookies() | Type error; .get() is not a function |
middleware.ts at the root, export function middleware() | src/proxy.ts, export function proxy() | Deprecated in 16.0; runtime default also flipped |
opengraph-image.tsx in a dynamic segment | Same file plus its own generateStaticParams | Builds fine, renders per-request |
| MDX/webpack plugins passed as imported functions | Plugins named as strings | Builds fine, plugin silently dropped |
The bottom two rows are the expensive ones, because neither fails the build and neither warns. The first three are loud — TypeScript stops you — and the fourth announces itself as a deprecation.
1–2. params and searchParams are promises
In Next.js 13 these were plain objects. Now they are promises, so a page is async and awaits them. This is the real file behind all 111 product URLs:
// src/app/templates/[slug]/page.tsx
type Params = { slug: string };
export function generateStaticParams(): Params[] {
return catalog.map((p) => ({ slug: p.slug }));
}
export default async function TemplateDetailPage({
params,
}: {
params: Promise<Params>;
}) {
const { slug } = await params;
// ...
}
Note generateStaticParams is not async here and returns the array directly — that one did not change. Only the per-request props became promises.
searchParams is the same shape, and it is worth seeing because it is where a 13-era habit bites hardest — reading a query param without awaiting gives you undefined rather than an error:
// src/app/login/page.tsx
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ next?: string; error?: string }>;
}) {
const { next, error } = await searchParams;
// ...
}
3. cookies() is async
Same treatment, and it changes the signature of anything that reads a cookie. The Supabase server client in this app is a factory rather than a constant purely because of it:
// src/lib/supabase/server.ts
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(/* ... */);
}
A Next.js 13 tutorial will show you const cookieStore = cookies() at module scope. That is now a type error, and the knock-on effect is that every caller becomes await createClient().
4. middleware.ts is now proxy.ts
Next.js 16.0 deprecated the middleware file convention and renamed it to proxy: the file is proxy.ts (project root, or inside src/ beside app/) and the exported function is proxy. Next's own version history records two changes in one line — "Middleware is deprecated and renamed to Proxy. Proxy defaults to the Node.js runtime" — and that second half matters as much as the rename, because 13-era middleware ran on the Edge runtime and code written against it may assume Edge-only APIs.
Deprecated is not removed, so a Next.js 13 middleware.ts is not an instant outage on 16. But it is on a deprecation path, and there is an official codemod rather than a manual rename:
npx @next/codemod@canary middleware-to-proxy .
It renames both the file and the function. Here is the migrated version in this app, which uses the hook for exactly one job — refreshing the Supabase session so Server Components see a valid one:
// src/proxy.ts — 42 lines, runs on every request
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(/* cookie plumbing */);
await supabase.auth.getClaims();
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|webp|gif|ico|webmanifest|html)$).*)",
],
};
The config.matcher export survived unchanged from 13, so the body of a 13-era middleware generally ports as-is once the file and function are renamed. The full treatment is in the Next.js 16 proxy migration. Note the matcher here deliberately excludes sitemap.xml and robots.txt: a crawler route has no session to refresh.
Next's docs are also blunt about when to reach for this at all — "we recommend users avoid relying on Middleware unless no other options exist". A session refresh that every route depends on is one of the cases that qualifies; an auth check you could do in a layout is not, and a layout is the better home for it.
5. opengraph-image.tsx needs its own generateStaticParams
Dynamic social cards were a Next.js 13 feature and the file convention is unchanged. The trap is that generateStaticParams on the page does not reach the image. This app has two opengraph-image.tsx files — src/app/opengraph-image.tsx (static, one card, needs nothing) and src/app/blog/[slug]/opengraph-image.tsx, which sits in a dynamic segment and therefore repeats the params itself:
// src/app/blog/[slug]/opengraph-image.tsx
export function generateStaticParams() {
return blogPosts.map((p) => ({ slug: p.slug }));
}
Without those four lines the build still succeeds and every card still renders — on demand, per request, for all 289 posts. You find out from your function invocation count, not from your terminal.
6. Turbopack takes plugin names as strings
Next.js 13 ran on webpack, where you passed plugins as imported JavaScript functions. Turbopack runs the MDX pipeline in Rust and cannot receive a function reference across that boundary — so it accepts a string and silently ignores anything else:
// next.config.ts
const withMDX = createMDX({
options: {
remarkPlugins: ["remark-gfm"],
rehypePlugins: ["rehype-slug"],
},
});
This one cost real time here. With remarkPlugins: [remarkGfm] — the imported function, exactly as every pre-Turbopack tutorial writes it — the build is green and every comparison table in every post renders as a paragraph of literal | characters, because MDX defaults to CommonMark and CommonMark has no table syntax. The tables above exist because that line is a string.
Troubleshooting a 13-era tutorial on 16
| Symptom | Cause | Fix |
|---|---|---|
params.slug is undefined | Read without await | const { slug } = await params |
cookies(...).get is not a function | cookies() not awaited | await cookies() |
Deprecation warning about middleware | File is still middleware.ts | npx @next/codemod@canary middleware-to-proxy . |
Markdown tables render as | pipes | Plugin passed as a function | remarkPlugins: ["remark-gfm"] |
| OG cards work but invocations climb | Dynamic opengraph-image has no params | Add generateStaticParams to the image file |
useState / onClick throws in a component | Server Component by default since 13 | Add "use client" — see use client |
The last row is not a 13→16 change at all; it is the 12→13 change people most often blame on a later version. Everything under app/ has been a Server Component by default since the app directory shipped.
FAQ
Is the app directory still called that?
The folder is still app/. The feature is now usually called the App Router — "app directory" was the Next.js 13 beta name, and it is still what people search for, which is why old tutorials remain the top results.
Can I still use pages/ in Next.js 16?
Yes. The two routers coexist in one project, which was true in 13.4 and is still true now. The Pages Router has not been deprecated.
Do I need to migrate a working Next.js 13 app?
Not for the app directory's sake — your folder tree is already correct. Budget the migration for the async-props change, which touches every page that reads params, searchParams or cookies(). In this codebase that was mechanical: the type changes, then the await.
Why do Next.js 13 tutorials still rank? Because the structural half of them is still right, and because "next.js 13 app directory" is a phrase people type. The drift is confined to about six APIs, which is what the table above is for.
Templates that already run this
Every pattern above is the shipped code in this storefront, and the same conventions are in each landing-page template — async params, a proxy.ts where auth is involved, and generateStaticParams on both the page and its OG image. If you would rather start from a tree that is already on 16 than port one from 13, the Next.js landing page templates and the Tailwind landing page templates are built that way.
