
Next.js page.js
Every other special file in the App Router's vocabulary — layout.js, template.js, loading.js, error.js — exists to wrap or coordinate something. page.js is the one file that's actually required to make any of that machinery meaningful: without a page.js somewhere in a route segment's tree, that segment isn't publicly reachable at all, no matter how many layouts and templates surround it.
This reference focuses on the two props a page can receive — params and searchParams — and specifically on why one of them behaves so differently from the other in terms of rendering strategy, which is the part of this file that actually causes design decisions elsewhere in an app.
The Minimal Shape
export default function Page({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
return <h1>My Page</h1>;
}
A handful of structural facts about page.js are worth stating plainly, since they're easy to take for granted:
- It's always the leaf of a route subtree — nothing renders "inside" a page the way children render inside a layout.
- A
pagefile is what makes a route segment publicly accessible at all. A folder with only alayout.jsand nopage.js(or nested segment that eventually has one) isn't a reachable URL. - Pages default to Server Components, same as everything else in the App Router, but can be Client Components via
"use client". - In the component hierarchy,
page.jsis the innermost file convention — wrapped byloading.js(a Suspense boundary),error.js(an error boundary),template.js, andlayout.js, all at the same segment.
params — Dynamic Route Parameters
A promise resolving to the dynamic segment values from the root down to this page:
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
}
| Example Route | URL | params |
|---|---|---|
app/shop/[slug]/page.js | /shop/1 | Promise<{ slug: '1' }> |
app/shop/[category]/[item]/page.js | /shop/1/2 | Promise<{ category: '1', item: '2' }> |
app/shop/[...slug]/page.js | /shop/1/2 | Promise<{ slug: ['1', '2'] }> |
As with every other place params shows up in the App Router — layouts, route handlers — it's a promise, not a plain object. await it, or use React's use() if you're in a Client Component page:
"use client";
import { use } from "react";
export default function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = use(params);
return <div>{slug}</div>;
}
Synchronous access to params (the pre-15 behavior) still technically works as a backward-compatibility path, but it's explicitly deprecated — new code shouldn't rely on it.
searchParams — Where This Reference Actually Gets Interesting
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const filters = (await searchParams).filters;
}
| Example URL | searchParams |
|---|---|
/shop?a=1 | Promise<{ a: '1' }> |
/shop?a=1&b=2 | Promise<{ a: '1', b: '2' }> |
/shop?a=1&a=2 | Promise<{ a: ['1', '2'] }> |
Structurally, searchParams looks nearly identical to params — both are promises, both are awaited or read via use(). But there's one line in the docs that changes how you should architect any page that reads it:
searchParamsis a Request-time API whose values cannot be known ahead of time. Using it will opt the page into dynamic rendering at request time.
This is a meaningfully bigger deal than it sounds. params, by contrast, can be known ahead of time — via generateStaticParams — and prerendered at build time. searchParams structurally cannot be, because a query string is something a visitor appends at will; there's no finite list of query strings to prerender against. The moment a page reads searchParams, that page opts into dynamic, request-time rendering — no static shell, no prerendering, for however much of the page depends on that value.
Under Cache Components specifically, where in your component tree you read searchParams directly determines how much of the page can still be prerendered around it. Push the searchParams read as deep as possible — into the specific leaf component that actually needs the filter value, rather than at the top of the page — and everything above that point in the tree can still be part of the static shell. Read it at the very top of page.js, and you've just made the entire page dynamic even if 95% of it doesn't actually depend on the query string. This is exactly the kind of structural decision that's invisible until you understand the rendering-strategy consequence, and it's the single most consequential detail in this entire reference.
One more small but real gotcha: searchParams is a plain JavaScript object, not a URLSearchParams instance. If you're used to URLSearchParams methods (.get(), .getAll(), .has()), they don't exist here — access fields with ordinary object property access instead.
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { page = "1", sort = "asc", query = "" } = await searchParams;
return (
<div>
<h1>Product Listing</h1>
<p>Search query: {query}</p>
<p>Current page: {page}</p>
<p>Sort order: {sort}</p>
</div>
);
}
Reading Both in a Client Component
Because a Client Component page can't be async, use use() for both props when you need them client-side:
"use client";
import { use } from "react";
export default function Page({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { slug } = use(params);
const { query } = use(searchParams);
}
The PageProps Helper
Rather than hand-writing the prop types shown throughout this article, PageProps<'/route'> is a globally-available, auto-generated helper that infers the exact shape from your route's actual file-system position:
export default async function Page(props: PageProps<"/blog/[slug]">) {
const { slug } = await props.params;
const query = await props.searchParams;
return <h1>Blog Post: {slug}</h1>;
}
Passing a literal route string ('/blog/[slug]' rather than a generic string) gets you autocomplete and strict key checking on params — the generated types know exactly which dynamic segments this specific route has. For a static route with no dynamic segments at all, params simply resolves to {}. These types are generated during next dev, next build, or next typegen, and once generated, PageProps is globally available without needing an explicit import.
Version History
| Version | Changes |
|---|---|
v15.0.0-RC | params and searchParams became promises; a codemod is available for the migration |
v13.0.0 | page introduced |
Key Takeaways
| Prop | Nature | Rendering impact |
|---|---|---|
params | Dynamic route segment values | Can be prerendered via generateStaticParams |
searchParams | Query string values | Reading it opts the page into dynamic, request-time rendering — no exceptions |
| Both | Promises | Must await or use React's use() |
searchParams object type | Plain object | Not a URLSearchParams instance — no .get()/.has() methods |
| Placement under Cache Components | Matters | Read searchParams as deep in the tree as possible to preserve the static shell above it |
The one thing worth carrying away from this entire reference: params and searchParams look like siblings on the page, but they have fundamentally different relationships with prerendering. Treat params as build-time-knowable and searchParams as inherently request-time, and place your reads of the latter as deep in the component tree as the logic allows — that single habit does more for a Cache-Components-era app's performance than almost any other decision you'll make in a page component.


