Migrating from next/router: Params, Shallow URLs and 404s in App Router
Leaving next/router behind: how params, useParams, shallow URL updates, programmatic navigation and real 404 pages work in the Next.js App Router instead.
This article is published in English.
Sources: Next.js routing: the four next/router habits I had to unlearn
Developers coming from the Pages Router often still reach for import { useRouter } from 'next/router'. In an App Router project that import is misleading: the module is still there, it may even type-check, yet router.query comes back empty and the bug is not obvious. Navigation in the App Router (the default since Next.js 13) lives in next/navigation, and several familiar Pages Router idioms simply no longer exist. This guide covers the four changes you will meet when porting a small blog to the App Router, with code that works on Next.js 16.3.1 and React 19 at the time of writing.
Dynamic params arrive as a prop, not from a hook
In the Pages Router, the usual approach was to make the page a client component and read router.query.id. In the App Router, a page is a server component unless you say otherwise, and Next.js passes it a params prop. Since Next.js 15, params is a Promise, so the component must be async and await it before reading any value.
The page imports notFound so it can bail out when the slug does not match a post:
import { notFound } from 'next/navigation'
The component below looks up the post in an in-memory map and calls notFound() for unknown ids.
const POSTS: Record<string, { title: string; body: string }> = {
'hello-world': { title: 'Hello World', body: 'The first post.' },
}export default async function BlogPost({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const post = POSTS[id]
if (!post) notFound() return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
)
}
There is no hook and no 'use client' directive here, and the lookup runs on the server before any HTML is sent. A request to /blog/hello-world against the production build returns the post title and body with a 200 status. Omitting await params is the most frequent migration mistake on Next.js 15 and later: the value you destructure is a promise, not the id.
Reading the segment on the client with useParams
Server-first rendering does not mean every component runs on the server. A comment form needs the post id in the browser. For that you use useParams from next/navigation instead of router.query. The file starts with the directive:
'use client'
Then the component reads the segment. Passing a type argument to useParams gives you a typed result:
import { useParams } from 'next/navigation'export default function CommentBox() {
const { id } = useParams<{ id: string }>()
return <form data-post={id}>Commenting on {id}</form>
}
Unlike the Pages Router version, it never returns null here: the segment is resolved before any client component below it renders. Rendered inside the post page, the form carries data-post="hello-world" on the very first paint, so no loading state is needed.
Shallow routing becomes a plain History API call
The Pages Router let you write router.push(url, as, { shallow: true }) to change the URL without refetching data. The App Router has no shallow option. To update the URL for things like sort order, filters or the active tab without re-running server components, call window.history.pushState yourself and read the value back through useSearchParams. Again the file is a client component:
'use client'
The control copies the current params, sets sort and pushes the new query string; newest is the fallback:
import { useSearchParams } from 'next/navigation'export default function SortControls() {
const searchParams = useSearchParams()
const active = searchParams.get('sort') ?? 'newest' function setSort(sort: string) {
const next = new URLSearchParams(searchParams.toString())
next.set('sort', sort)
window.history.pushState(null, '', `?${next.toString()}`)
} return (
<div>
<button aria-pressed={active === 'newest'} onClick={() => setSort('newest')}>
Newest
</button>
<button aria-pressed={active === 'top'} onClick={() => setSort('top')}>
Top
</button>
</div>
)
}
Next.js syncs native pushState and replaceState calls with its router, so useSearchParams sees the new value and the buttons update without a server round trip. Two pitfalls are worth knowing:
- A component that calls
useSearchParamsshould sit inside a<Suspense>boundary. Without one, the page falls back to client-side rendering up to the nearest boundary instead of being statically rendered, and the build output warns you about it. pushStateadds a history entry for every click. If pressing Back through a series of sort changes would annoy users, usereplaceStateinstead.
Programmatic navigation still uses useRouter, from a new path
When navigation should go through the router, such as sending the user home after a form submit, useRouter remains the tool. Only the import path changes. It also needs the directive:
'use client'
And the call itself is unchanged:
import { useRouter } from 'next/navigation'export default function GoHome() {
const router = useRouter()
return <button onClick={() => router.push('/')}>Home</button>
}
router.push('/') behaves as before; the whole trap is the import line.
Custom 404 pages are a file that sends a real 404 status
The old pages/404.js convention is replaced by an app/not-found.tsx file, which renders whenever notFound() is called, including from the server component in the first section. The page links back to the index, so it imports Link:
import Link from 'next/link'
The component itself is ordinary markup:
export default function NotFound() {
return (
<main>
<h1>404 — no such post</h1>
<Link href="/">Back to the index</Link>
</main>
)
}
The status code matters: a soft 404, an error-looking page served with 200, can hurt search indexing because crawlers treat it as real content. Requesting a missing post such as /blog/nope returns HTTP 404 along with the "no such post" body. notFound() sets that status, so what crawlers see matches what users see.
Let the build output confirm the migration
Once the port is done, the route table printed by next build is the quickest way to check the result:
Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /blog/[id]
The ƒ marker shows that /blog/[id] is rendered dynamically because it reads params, while ○ marks the static index and not-found routes. None of the code relies on next/router, router.query or shallow: true. For choosing what runs where, see where to draw the server-client line in an App Router page.
Key takeaways
- Search the codebase for
from 'next/router'first; each match points to one of these four changes. - Read dynamic segments from the awaited
paramsprop in server components, and fromuseParamsin client components. - Replace
shallow: truewithhistory.pushStateorreplaceStateplususeSearchParams, wrapped in<Suspense>. - Keep
useRouterfor real navigations, imported fromnext/navigation. - Use
app/not-found.tsxwithnotFound()to get a genuine 404 status instead of a soft 404.