Part 3 of 5 in PHP to Next.js

Your Route Parameters Are a Promise Now — the PHP Mental Model That Breaks in Next.js 16

In Symfony, $request->attributes->get('slug') is synchronous. In Next.js 16, params is a Promise. Forget the await and your page silently fails.

In Symfony, reading a route parameter is a synchronous operation:

php
$slug = $request->attributes->get('slug');

In Laravel, the same:

php
$slug = $request->route('slug');

Both are immediate. You write the line, you get the value. If the parameter exists, you have it. This is so fundamental that you never think about it.

Next.js 16 does not work this way.

The change

Starting with Next.js 15, route parameters (params) and search parameters (searchParams) are Promises, not plain objects:

tsx
// Next.js 14 and earlier — WRONG in Next.js 16
export default function Page({ params }: { params: { slug: string } }) {
  const { slug } = params   // slug is string — works in 14, broken in 16
}

The correct version:

tsx
// Next.js 16
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params   // must await
}

The function signature itself changes: params is typed as a Promise, and the component must be async to await it. Two things a PHP developer would never expect from reading a URL parameter.

Why this change

Next.js 16 defers parameter resolution to enable streaming and concurrent rendering. By making params a Promise, the framework can start rendering a page before knowing the full parameter set, then resolve it when ready. It is an architectural choice for performance — not a gratuitous API change.

But from the perspective of someone coming from PHP, where URL parameters are resolved synchronously before your controller even starts, this feels like solving a problem that doesn't exist. Why would reading a slug need to be asynchronous?

The PHP reflex

The mistake is not writing await for a value that, in your mental model, should be instant. TypeScript will not always catch this — if you type params as { slug: string } instead of Promise<{ slug: string }>, the compiler trusts you and the runtime behaviour is unpredictable. The page may render with undefined values, or throw an error at an unrelated point, with no clear path back to the missing await.

The fix is a one-character addition (await) and a one-keyword addition (async on the function). But knowing why it's needed requires understanding that the framework deferred something that, in every other stack, is resolved before your code runs.


Delaa