GioJSdocs
On this page

Layouts & Pages

Build routes with page files and share UI with nested layouts.

Pages

A page is the default export of a page.tsx file. It renders the UI for a route.

tsx
export default function Page() {
  return <h1>Hello, world</h1>;
}

Layouts

A layout.tsx wraps the pages in its folder and all nested folders. The root app/layout.tsx must render <html> and <body>.

tsx
// app/layout.tsx - server-only HTML
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

// app/(site)/layout.tsx - hydrated: the Navbar's links soft-navigate
export default function SiteLayout({ children }) {
  return (
    <>
      <Navbar />
      <main>{children}</main>
    </>
  );
}

Layouts follow the folder tree, not the URL: a page gets every layout.tsx in its own folder and each folder above it, outermost first. That includes layouts inside dynamic folders (app/posts/[id]/layout.tsx wraps every post) and inside route groups. The root app/layout.tsx is server-only HTML; the layouts nested under it render inside the hydrated region and ship in the page's client bundle. A not-found.tsx or error.tsx gets the layouts of its own folder and the folders above it - never those of the page below it that failed (see Error Handling).

Loading UI

A loading.tsx wraps everything below its folder - the page and the layouts of deeper folders - in a <Suspense> boundary with its default export as the fallback. When the page suspends while rendering (React's use() on a promise, a lazy component), a streamed response sends the layouts and the loading UI at once and the page as soon as it is ready.

app/dashboard/loading.tsx
export default function Loading() {
  return <p>Loading dashboard…</p>;
}

getServerSideProps runs before rendering starts, so the loading UI does not cover it - it shows only for content that suspends during the render. Per folder, the boundary sits inside the folder's layout and its error.tsx boundary:

text
<Layout>               dashboard/layout.tsx
  <ErrorBoundary>      dashboard/error.tsx
    <Suspense>         dashboard/loading.tsx
      <Page />
  • A page that renders without suspending looks exactly as before; the boundary only adds React's Suspense markers to the HTML. A cacheable page is still rendered completely and cached.
  • If the page throws or calls notFound() before it suspends, the response is still the error or not-found page, as without the loading.tsx. After it has suspended, the 200 and the loading UI are already on their way, so errors are handled like in any Suspense boundary (see Error Handling) - a page that suspends and then throws, a 500 without the loading.tsx, is a 200 with it. Streamed or rendered completely (a cacheable page), the answer is the same.
  • With partial prerendering (shell = 'cache') the boundary is a shell edge like any <Suspense>: when the page suspends, the cached shell holds the layouts above it plus the loading UI, and the page streams per request as a hole (see Caching Layers).
  • Client-side navigation keeps the current page on screen until the next page's HTML has arrived (prefetching hides most of that wait), then renders it into the same React tree: layouts the two pages share keep their state (a layout inside a dynamic segment mounts fresh when that segment's value changes), and the loading UI shows only if the new page suspends in the browser.

Which pages stream, granular <Suspense> boundaries and the data a suspending component may read are covered in Streaming.

Dynamic routes

Wrap a folder name in brackets to capture URL segments. Params reach ctx.params in getServerSideProps (and the params prop when there is none) as strings.

FolderPatternMatchesparams
posts/[id]/posts/:id/posts/1 (exactly one segment){ id: '1' }
docs/[...slug]/docs/*slug/docs/a, /docs/a/b - not /docs{ slug: 'a/b' }
shop/[[...path]]/shop/*path?/shop, /shop/a, /shop/a/b{ path: '' } for /shop

A catch-all value is one string that keeps its / separators - split it yourself if you need the segments. An optional catch-all that matches nothing is the empty string. A catch-all must be the last segment of its route, and a param name may appear only once per route.

Param values are not percent-decoded: /blog/caf%C3%A9 gives { slug: 'caf%C3%A9' } and /blog/hello%20world gives 'hello%20world' (only escapes of unreserved characters, such as %41, arrive decoded). Call decodeURIComponent() where you need the text. This is on purpose: decoding %2F inside one segment would hand your code a / - or a ../ - it never saw in the path. See Param values.

Route groups

Wrap a folder name in parentheses to organize routes without changing URLs: app/(marketing)/about/page.tsx serves /about. Groups work for pages, route.ts handlers, and layouts, so a group can carry its own layout:

text
app/
  (marketing)/
    layout.tsx        # wraps /about and /pricing only
    about/page.tsx    # /about
    pricing/page.tsx  # /pricing
  (shop)/
    layout.tsx        # wraps /cart only
    cart/page.tsx     # /cart

A group layout is always a nested layout; only app/layout.tsx renders the document.

Private folders

Folders whose name starts with an underscore (app/_components) are never routable - nothing inside them becomes a page, handler, or layout. Use them to colocate components and helpers with the routes that use them.

Matching order and conflicts

When several patterns match a URL, the most specific wins, compared segment by segment from the left: a static segment beats [id], which beats [...slug], which beats [[...slug]]. So with both shop/page.tsx and shop/[[...path]]/page.tsx, /shop renders the static page and /shop/a the catch-all.

Pages and route.ts handlers share this order: blog/about/page.tsx serves /blog/about even beside blog/[slug]/route.ts, and a catch-all route.ts never shadows the pages below it.

Two files that would answer the same URLs fail startup with an error naming both: two pages in different groups ((a)/about and (b)/about), posts/[id] next to posts/[slug], or a page and a route.ts in different folders. So does docs/[...a] next to docs/[[...b]]: the catch-all wins every URL below /docs, which would leave the optional catch-all only /docs itself. A route.ts in the same folder as its page.tsx is fine - see Route Handlers.