Skip to main content
ASoc
Tutorial

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 ASoc Team8 min read

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 wroteNext.js 16 wantsBreaks 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 segmentSame file plus its own generateStaticParamsBuilds fine, renders per-request
MDX/webpack plugins passed as imported functionsPlugins named as stringsBuilds 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

SymptomCauseFix
params.slug is undefinedRead without awaitconst { slug } = await params
cookies(...).get is not a functioncookies() not awaitedawait cookies()
Deprecation warning about middlewareFile is still middleware.tsnpx @next/codemod@canary middleware-to-proxy .
Markdown tables render as | pipesPlugin passed as a functionremarkPlugins: ["remark-gfm"]
OG cards work but invocations climbDynamic opengraph-image has no paramsAdd generateStaticParams to the image file
useState / onClick throws in a componentServer Component by default since 13Add "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.

Keep reading

Tutorial11 min read

Next.js 16 Renamed Middleware to Proxy: What Changes and What Breaks

Renaming the file is a third of the migration. The exported function has to change too, keeping both files is a build error, and the proxy runs on Node rather than the Edge.

Read more
Tutorial11 min read

A/B Testing in Next.js 16: The Proxy Recipe Costs the CDN, Not SSG

Rewriting in middleware does not make your pages dynamic — both variants stay prerendered. What it really costs is the shared cache and every request's critical path.

Read more
Tutorial11 min read

An Accessible Mega Menu in Next.js Without a Headless UI Library

It is a navigation landmark, not an application menu — and the ARIA menu pattern most tutorials copy removes your nav from every screen reader's link list. Six behaviours, eighty lines.

Read more