Project Structure

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3 (16.3.8 was the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation, @doc-version 16.3.8, was read on the same day), React 19.3.0, Node.js 20.9 or later and TypeScript 5.1 or later. It describes the App Router (the app folder) in depth and the Pages Router (the pages folder) as a comparison. The file and folder conventions are the same under Cache Components and under the previous caching model; the examples on this page were built and run with the previous model (cacheComponents off), because the scratch pages read params and cookies() directly in layouts, which Cache Components rejects at build time unless the access sits under Suspense (the caching pages cover that). Every example was built with next build and, where it matters, served with next start in a scratch project that is a reduced version of the Bookshelf used throughout this section, so the route tables and curl outputs shown here are real output, not hand-written.

The page answers three questions in order: which folders and files does Next.js give a meaning to, how do you arrange everything else without changing your URLs, and what does the finished Bookshelf project look like. Pages that go deeper on single conventions (the routing pages, the configuration and TypeScript page, the data and caching pages) are named in plain prose where they matter. The Next.js section of this site has not been linked in the navigation yet, so those pages are not cross-referenced here. For the React concepts underneath (components, Suspense, error boundaries), see the React routing page; for the same Bookshelf on the server side of a different stack, see NestJS vs Next.js.

Conventions, Not Configuration

Next.js discovers your application from the file system. A folder inside app is a route segment; a small set of reserved file names inside it gives the segment a UI, a boundary or an endpoint; everything else is yours. The official page is explicit that Next.js is unopinionated about how you organise and colocate the rest of your project, and gives you tools (colocation, private folders, route groups, src) rather than a mandatory layout.

Three rules explain almost everything that follows:

  • Folders define URL segments. Nesting folders nests segments, and a layout at any level wraps all of its child segments.

  • Only special file names do anything. A route becomes publicly reachable only when it contains a page or a route file (or a metadata route such as sitemap.ts). Any other file next to them is just a module.

  • Names in brackets or parentheses are syntax, not folder names: [id] is a dynamic segment, (group) is invisible in the URL, _folder is invisible to routing, @slot is a named slot and (.)folder intercepts a route.

Top-Level Folders

Table 1. Top-level folders of a Next.js project
Folder Purpose Notes

app

The App Router: file-system routes, layouts, Server Components and Route Handlers.

The recommended router for new projects. A root layout (with <html> and <body>) is required.

pages

The Pages Router: one file per route, getStaticProps/getServerSideProps, pages/api.

Still supported; both routers can live in the same project, which is how incremental migration works.

public

Static assets served from the base URL: public/avatars/me.png is served at /avatars/me.png.

Next.js cannot safely cache these files, so it sends Cache-Control: public, max-age=0. For robots.txt, favicons and similar files use the metadata file conventions inside app instead.

src

Optional folder that holds app or pages (and usually components, lib, proxy.ts) so that application code is separated from configuration files at the root.

public, package.json, next.config., tsconfig.json and .env files stay in the project root. See the src folder below.

A freshly generated project and the final Bookshelf both contain a top-level public folder. This is the behaviour of a file in public, as served by the scratch project:

A file in public/ is served from the root, with the default caching header
$ curl -sI localhost:3203/file.svg | grep -i -E "^(HTTP|content-type|cache-control)"
HTTP/1.1 200 OK
Cache-Control: public, max-age=0
Content-Type: image/svg+xml

Source: public folder

Top-Level Files

Top-level files configure the application, manage dependencies, run the Proxy, integrate monitoring and define environment variables.

Table 2. Top-level files (project root, or inside src where noted)
File Purpose Notes

next.config.ts (also .js, .mjs)

Configuration of Next.js itself.

next.config.ts is supported for TypeScript projects. Always in the project root.

package.json

Dependencies and scripts (dev, build, start).

Minimum manual install: next, react, react-dom.

proxy.ts (also .js)

The Proxy: code that runs on the server before a request is completed (rewrite, redirect, header changes).

Same level as app or pages, so inside src when you use src. Renamed from middleware.ts in Next.js 16.

instrumentation.ts (also .js)

Server-side observability hook: register() once per server instance and optional onRequestError().

Root, or inside src. Stable since Next.js 15.

instrumentation-client.ts (also .js)

Client-side code that runs after the HTML is loaded and before React hydration starts; may export onRouterTransitionStart.

Root, or inside src. Introduced in Next.js 15.3.

mdx-components.tsx (also .js)

Required to use @next/mdx with the App Router; defines the components MDX content uses.

Root, or inside src, at the same level as app or pages.

.env, .env.local, .env.development, .env.production

Environment variables.

Should not be tracked by version control (at least .env.local). The load order and the NEXT_PUBLIC_ rules are covered on the configuration page.

eslint.config.mjs

ESLint flat configuration.

Linting runs through the ESLint CLI; next lint was removed in Next.js 16.

tsconfig.json / jsconfig.json

TypeScript or JavaScript configuration, including paths aliases.

Next.js adds the next TypeScript plugin and its generated type folders.

next-env.d.ts

Generated TypeScript declarations for Next.js.

Generated; should not be tracked by version control.

.gitignore

Files and folders Git ignores.

Must cover .next/, node_modules/ and .env* files.

AGENTS.md, CLAUDE.md

Instructions for AI coding agents. The Next.js block points agents at the version-matched documentation bundled in node_modules/next/dist/docs/.

Generated by create-next-app (opt out with --no-agents-md); CLAUDE.md contains the single line @AGENTS.md.

Two of these files are worth showing because they run in places a component never does. instrumentation.ts executes on the server when an instance starts; instrumentation-client.ts is bundled into the client.

instrumentation.ts and instrumentation-client.ts at the project root
// instrumentation.ts - server, once per server instance
export function register() {
  console.log("[instrumentation] register() called once per server instance");
}

// instrumentation-client.ts - browser, after the HTML loads and before hydration
performance.mark("bookshelf-client-init");

export function onRouterTransitionStart(url: string, navigationType: string) {
  console.log("navigation", navigationType, url);
}

Starting the production server printed the server-side line once, and the client file was found in a chunk that is loaded on every page:

Output of next start, and a search of the client chunks for the marker string
$ PORT=3203 npx next start
▲ Next.js 16.3.8
- Local:         http://localhost:3203
✓ Ready in 71ms
✓ Running next.config.ts took 17ms
[instrumentation] register() called once per server instance

$ grep -l "bookshelf-client-init" -r .next/static
.next/static/chunks/0k7ommvl0jg7u.js

The Proxy file follows the same placement rule. The Bookshelf one redirects every request that has no locale prefix; its matcher leaves API routes, _next assets and files with an extension (such as /file.svg or /favicon.ico) alone, which matters because without a matcher the Proxy runs on every request, including static files.

proxy.ts - the exported function is named proxy
import { NextResponse, type NextRequest } from "next/server";
import { locales, defaultLocale } from "@/lib/i18n";

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  if (locales.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`))) {
    return NextResponse.next();
  }
  return NextResponse.redirect(new URL(`/${defaultLocale}${pathname}`, request.url));
}

export const config = {
  matcher: ["/((?!api|_next|.*\\..*).*)"],
};

Source: proxy.js

mdx-components.tsx is the one top-level file that only matters when you use MDX. With @next/mdx installed, the following configuration built and prerendered an app/(shop)/about/page.mdx page in the scratch project:

next.config.ts and mdx-components.tsx for MDX pages
// next.config.ts
import createMDX from "@next/mdx";
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};

const withMDX = createMDX({});

export default withMDX(nextConfig);

// mdx-components.tsx
import type { MDXComponents } from "mdx/types";

const components: MDXComponents = {};

export function useMDXComponents(): MDXComponents {
  return components;
}

The generated AGENTS.md is a managed block. On Next.js 16.3 and later next dev re-adds it when an AI coding agent is detected, and anything you write outside the markers is preserved:

AGENTS.md as generated by create-next-app 16.3.8
<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data.
Read the relevant guide in `node_modules/next/dist/docs/` ... before writing any code. Heed deprecation notices.

<!-- END:nextjs-agent-rules -->

The File-Convention Table

The next four tables list every file convention of the App Router, derived from the project-structure page and the File-system conventions reference of the 16.3.8 documentation. Where a file has a JavaScript and a TypeScript form, the table gives the accepted extensions.

Routing Files

Table 3. Routing files of the App Router (every one lives inside a route segment folder)
File Extensions What it does

layout

.js .jsx .tsx

Shared UI that wraps the segment and its children. Preserves state and does not re-render on navigation between child routes. The root layout (app/layout) must define <html> and <body>.

page

.js .jsx .tsx

The UI unique to a route and what makes the segment publicly accessible. Always the leaf of the route subtree; a Server Component by default. Receives params and searchParams as promises.

loading

.js .jsx .tsx

An instant loading state: Next.js wraps the segment’s page and children in a React Suspense boundary and uses this file as the fallback while content streams in.

not-found

.js .jsx .tsx

UI rendered when notFound() is thrown inside the segment; the root app/not-found also handles unmatched URLs.

global-not-found

.js .jsx .tsx

Experimental (since 15.4, behind experimental.globalNotFound). One 404 page for the whole app that bypasses rendering of any layout, so it must define its own <html> and <body>. Useful with multiple root layouts.

error

.js .jsx .tsx

A React error boundary (a Client Component) for the segment’s page and children, with a retry function. Does not catch errors of the layout or template of the same segment.

global-error

.js .jsx .tsx

Replaces the root layout when an error escapes it; must define its own <html> and <body>. Lives in the root app folder.

route

.js .ts

A Route Handler: an endpoint exporting GET, POST and the other HTTP method functions. A segment cannot have both route and page.

template

.js .jsx .tsx

Like a layout, but a new instance is mounted for each child on navigation, so state is reset and effects re-run.

default

.js .jsx .tsx

Fallback for a parallel-route slot (and the implicit children slot) when Next.js cannot recover the slot’s active state after a full page load.

forbidden

.js .jsx .tsx

UI for the forbidden() function (HTTP 403). Experimental, needs experimental.authInterrupts; introduced in 15.1.

unauthorized

.js .jsx .tsx

UI for the unauthorized() function (HTTP 401). Experimental, needs experimental.authInterrupts; introduced in Next.js 15.1.

Several of these are one-file examples. The error boundary and the 401/403 files are the ones people most often get wrong, so here they are as built in the scratch project. error.tsx must be a Client Component and receives retry (stable in 16.3.0; unstable_retry in 16.2). The forbidden.tsx and unauthorized.tsx files sit in the root of app and only render once experimental.authInterrupts is on and a page calls forbidden() or unauthorized().

error.tsx, forbidden.tsx and the page that triggers it
// app/[locale]/(shop)/books/[id]/error.tsx
"use client";

export default function BookError({ retry }: { error: Error; retry: () => void }) {
  return <button onClick={() => retry()}>Try again</button>;
}

// app/forbidden.tsx
export default function Forbidden() {
  return <h1>403 - you may not see this</h1>;
}

// app/[locale]/(account)/admin/page.tsx
import { forbidden } from "next/navigation";
import { getUser } from "@/lib/dal";

export default async function AdminPage() {
  const user = await getUser();
  if (user?.id !== "admin") forbidden();
  return <h1>Admin</h1>;
}
next.config.ts - the flag that enables forbidden() and unauthorized()
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: { authInterrupts: true },
};

export default nextConfig;

Against the built project, a user with a session cookie that is not the admin gets the 403, the admin gets the page and anyone without a cookie is redirected by the account layout (the scratch session cookie only stands in for a real session; real authentication belongs to the Data Access Layer, as the authentication page shows):

forbidden() returns a real 403 status
$ curl -s -o /dev/null -w "no cookie  %{http_code} %{redirect_url}\n" localhost:3203/en/admin
no cookie  307 http://localhost:3203/en/sign-in
$ curl -s -o /dev/null -w "user       %{http_code}\n" -H 'Cookie: session=u1' localhost:3203/en/admin
user       403
$ curl -s -o /dev/null -w "admin      %{http_code}\n" -H 'Cookie: session=admin' localhost:3203/en/admin
admin      200

A route file is the App Router replacement of a Pages Router API route. It is the one routing file that is not a React component:

app/api/books/route.ts - a Route Handler
import { listBooks } from "@/lib/books";

export async function GET() {
  return Response.json(await listBooks());
}
Calling the handler
$ curl -s localhost:3203/api/books
[{"id":"1","title":"Dune","author":"Frank Herbert"},{"id":"2","title":"Neuromancer","author":"William Gibson"},{"id":"3","title":"Solaris","author":"Stanislaw Lem"}]

Source: route.js

global-not-found is the odd one out in the table, so it was checked separately in a project with two root layouts (see multiple root layouts): with experimental.globalNotFound enabled, a request to an unknown URL returned the file’s own document and a 404 status.

app/global-not-found.tsx - own html and body, because no layout renders around it
import type { Metadata } from "next";

export const metadata: Metadata = { title: "404 - Not Found" };

export default function GlobalNotFound() {
  return (
    <html lang="en">
      <body>
        <h1>404 - Page Not Found</h1>
      </body>
    </html>
  );
}
Unmatched URL with experimental.globalNotFound enabled
$ curl -s -w "\n%{http_code}\n" localhost:3203/nope | grep -o '<title>[^<]*\|<h1>[^<]*\|^[0-9]*$'
<title>404 - Not Found
<h1>404 - Page Not Found
404

Source: not-found.js

Folder Conventions

Folder names carry the rest of the routing syntax.

Table 4. Folder name conventions
Pattern Example Meaning

folder

app/blog/authors/page.tsx is /blog/authors

A route segment.

[segment]

app/blog/[slug]/page.tsx is /blog/my-first-post

A dynamic segment; the value arrives in params.

[…​segment]

app/shop/[…​slug]/page.tsx is /shop/clothing/shirts

Catch-all segment.

[[…​segment]]

app/docs/[[…​slug]]/page.tsx is /docs and /docs/layouts-and-pages

Optional catch-all segment.

(group)

app/(shop)/cart/page.tsx is /cart

Route group: organisation only, omitted from the URL.

_folder

app/blog/_components/Post.tsx is not routable

Private folder: the folder and all its subfolders are opted out of routing. Use %5Ffolder for a URL segment that starts with an underscore.

@slot

app/@team/page.tsx

Named slot, rendered by the parent layout as a prop.

(.)folder

app/[locale]/@modal/(.)books/[id]

Intercept a route at the same level.

(..)folder

app/feed/(..)photo/[id]

Intercept a route one level up (segments, not folders: groups and slots do not count).

(..)(..)folder

two levels up

Intercept two levels up.

(…​)folder

from the root app

Intercept from the root.

Metadata Files

File-based metadata is defined by adding special files to route segments. Each convention has a static form (an image or text file) and a generated form (a .js, .ts or .tsx file that exports a function); Next.js serves the file (with hashes in production for caching) and adds the matching tags to the page head. When you use a Proxy, the metadata files must be excluded in its matcher.

Table 5. Metadata file conventions
File Static extensions Generated extensions Valid location

favicon

.ico

(none)

Root of app only.

icon

.ico .jpg .jpeg .png .svg

.js .ts .tsx

Any segment of app.

apple-icon

.jpg .jpeg .png

.js .ts .tsx

Any segment of app.

opengraph-image

.jpg .jpeg .png .gif

.js .ts .tsx

Any route segment (alt text in an opengraph-image.alt.txt beside it).

twitter-image

.jpg .jpeg .png .gif

.js .ts .tsx

Any route segment.

sitemap

.xml

.js .ts

Root of app, or nested in segments to split a sitemap.

robots

.txt

.js .ts

Root of app.

manifest

.json .webmanifest

.js .ts

Root of app.

The Bookshelf uses the generated form of four of them, plus a static icon.svg. Building them produced route-table entries (they are special Route Handlers) and the tags shown below:

app/robots.ts, app/sitemap.ts and app/manifest.ts (three files, shown together)
// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return { rules: { userAgent: "*", allow: "/" } };
}

// app/sitemap.ts
import type { MetadataRoute } from "next";

export default function sitemap(): MetadataRoute.Sitemap {
  return [{ url: "https://bookshelf.example/en" }];
}

// app/manifest.ts
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return { name: "Bookshelf", short_name: "Bookshelf", start_url: "/en" };
}
app/[locale]/(shop)/books/[id]/opengraph-image.tsx - a generated image for one segment
import { ImageResponse } from "next/og";
import { getBook } from "@/lib/books";

export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image({
  params,
}: {
  params: Promise<{ locale: string; id: string }>;
}) {
  const { id } = await params;
  const book = await getBook(id);
  return new ImageResponse(
    <div style={{ display: "flex", fontSize: 72 }}>{book?.title ?? "Bookshelf"}</div>,
    size,
  );
}
What the metadata files produce
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" localhost:3203/manifest.webmanifest
200 application/manifest+json
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" localhost:3203/icon.svg
200 image/svg+xml
$ curl -s localhost:3203/en/books/1 | grep -o '<meta property="og:image"[^>]*>\|<link rel="manifest"[^>]*>'
<link rel="manifest" href="/manifest.webmanifest"/>
<meta property="og:image" content="http://localhost:3000/en/books/1/opengraph-image-1ksjp1?c187f912feb7bc2b"/>

The og:image URL uses the default http://localhost:3000 base because no metadataBase was configured; a real project sets metadataBase in its root metadata. The segment’s image file gets a hashed name in the URL, as the route table below shows.

Root-Level and Config Conventions

Beyond the routing and metadata files, the reference lists the root-level files already shown in Top-Level Files (proxy, instrumentation, instrumentation-client, mdx-components) and one convention that is configured inside route files rather than being a file of its own: Route Segment Config (dynamicParams, maxDuration, runtime, prefetch, instant, and the deprecated preferredRegion), which is covered with routing and rendering.

Table 6. Everything else the file-conventions reference lists
Convention Where it is covered

public folder

Top-Level Folders.

src folder

The src folder.

Route Groups, Parallel Routes, Intercepting Routes, Dynamic Segments

Folder Conventions and the routing pages of this section.

Route Segment Config options

The routing and rendering pages (dynamicParams links to generateStaticParams).

Component Hierarchy

The components defined in special files are rendered in a fixed hierarchy, from the outside in: layout.js, then template.js, then error.js (a React error boundary), then loading.js (a React Suspense boundary), then not-found.js (a React error boundary for the "not found" UI) and finally page.js or a nested layout.js. In nested routes the components of a segment are nested inside the components of its parent segment.

flowchart TB subgraph L["layout.js - persists across navigations"] subgraph T["template.js - new instance for each navigation"] subgraph E["error.js - React error boundary"] subgraph S["loading.js - Suspense boundary"] subgraph N["not-found.js - boundary for notFound()"] P["page.js, or the nested layout.js of the child segment"] end end end end end

Two consequences follow from the nesting, and both are easy to get wrong:

  • error.js sits inside the layout and template of its own segment, so it cannot catch their errors. An error in the root layout needs global-error.js, which replaces the root layout while active.

  • loading.js wraps the page and children but not the layout, so it never shows while the layout itself is slow; a slow layout therefore blocks the segment’s loading UI.

To see the order in the rendered output, the scratch project has a segment app/[locale]/(shop)/demo that contains all of layout, template, error, loading and page, with a data-file attribute on each wrapper. The page calls connection() so it is rendered at request time and the fallback is streamed first:

The demo segment: one file per convention (error.tsx is a Client Component boundary and leaves no DOM)
// layout.tsx
export default function Layout({ children }: LayoutProps<"/[locale]/demo">) {
  return <div data-file="layout">{children}</div>;
}

// template.tsx
export default function Template({ children }: { children: React.ReactNode }) {
  return <div data-file="template">{children}</div>;
}

// loading.tsx
export default function Loading() {
  return <p data-file="loading">loading...</p>;
}

// page.tsx
import { connection } from "next/server";

export default async function Page() {
  await connection(); // request-time rendering, so loading.tsx is streamed first
  await new Promise((r) => setTimeout(r, 200));
  return <p data-file="page">page</p>;
}
The initial HTML of /en/demo: layout contains template contains the Suspense fallback; the page arrives later
<div data-file="layout"><div data-file="template"><!--$?--><template id="B:0"></template><p data-file="loading">loading...</p><!--/$--></div></div>
...
<div hidden id="S:0">...</div>
<div hidden id="S:1"><p data-file="page">page</p></div>

The streamed <div hidden> chunks hold the page content that React swaps into the fallback’s place, which is the loading UI doing its job. The full hierarchy for a nested route repeats the box, with the child segment’s layout.js taking the place of page.js at each level until the leaf page.js.

Colocation

Because only page and route files make a segment public, and only their returned content is sent to the client, project files can be safely colocated inside route segments without becoming routable. The decision tree is short:

flowchart TD F["File inside app/"] --> Q1{"Inside a _private folder?"} Q1 -->|yes| NR1["Never routable, nor its subfolders"] Q1 -->|no| Q2{"File name is page, route or a metadata route?"} Q2 -->|no| NR2["Plain module - not routable, can be imported"] Q2 -->|yes| Q3{"Another page or route at the same URL?"} Q3 -->|yes| ERR["Build error: conflicting paths"] Q3 -->|no| R["Public route"]

The Bookshelf book page keeps a helper and a test next to page.tsx:

A segment with colocated files
app/[locale]/(shop)/books/[id]/
├── page.tsx           # routable: exports the page
├── format-title.ts    # colocated helper, imported by page.tsx
├── page.test.tsx      # colocated test
├── error.tsx
├── loading.tsx
└── opengraph-image.tsx
Colocated helper (format-title.ts) used by page.tsx
// A colocated helper: sits next to page.tsx but is not routable
export const formatTitle = (t: string) => t.toUpperCase();
Only the page is reachable; colocated files are 404
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3203/en/books/1
200
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3203/en/books/1/format-title
404
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3203/en/books/1/page.test
404
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3203/en/books/1/page
404

The two rules that look like the opposite of colocation are conflicts. A segment with both a page and a route, and two route groups that resolve to the same URL, fail the build instead of picking a winner (messages from the scratch builds):

Build errors for conflicting routes
Error: Conflicting route and page at /: route at /(shop)/route and page at /(shop)/page

Error: You cannot have two parallel pages that resolve to the same path. Please check /(account)/books and /(shop).

Colocation is optional. You can also keep project files outside app, in top-level folders such as components and lib (the strategy the final Bookshelf layout uses), or put top-level folders inside app, or split files by feature under the route segments that use them; see Organisation strategies.

Source: Colocation

Private Folders

A folder prefixed with an underscore, folderName, is a private implementation detail: it and every subfolder are opted out of routing. Colocation already makes private folders unnecessary for safety, but they help to separate UI logic from routing logic, to organise internal files consistently, to sort and group files in editors and to avoid clashes with future Next.js file conventions. The _Bookshelf uses _components next to the pages that use the components. To prove the opt-out, the scratch project also has a page.tsx inside a private _internal folder:

A page.tsx inside a private folder, and the %5F escape for a real underscore segment
app/[locale]/(shop)/
├── _components/book-card.tsx   # imported by page.tsx
├── _internal/page.tsx          # a page, but inside a private folder
└── %5Fstatus/page.tsx          # URL segment that starts with an underscore
A private folder is invisible to routing; %5F produces an underscore URL
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3203/en/_internal
404
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3203/en/internal
404
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3203/en/_status
200

The route table (shown in full later) lists /[locale]/_status and has no entry for _internal.

Source: Private folders

Route Groups

A route group is a folder wrapped in parentheses, (folderName). It exists for organisation and is not part of the URL. Typical uses are to organise routes by site section, intent or team, to share a layout between a subset of routes of the same segment level, and to create multiple root layouts. In the Bookshelf, (shop) and (account) share the [locale] segment and the URL space, yet have different layouts: the account layout requires a session and the shop layout does not.

app/[locale]/(account)/layout.tsx - a layout that applies to the account routes only
import { requireUser } from "@/lib/dal";
import { SiteHeader } from "@/components/ui/site-header";

export default async function AccountLayout({
  children,
  params,
}: LayoutProps<"/[locale]">) {
  const { locale } = await params;
  await requireUser(locale);
  return (
    <>
      <SiteHeader locale={locale} />
      {children}
    </>
  );
}

Everything the group does is invisible in the URL and in the route table:

Folders and the URLs they produce
app/[locale]/(shop)/page.tsx                  /en
app/[locale]/(shop)/books/[id]/page.tsx       /en/books/1
app/[locale]/(account)/my-reviews/page.tsx    /en/my-reviews
app/[locale]/(account)/admin/page.tsx         /en/admin

The documentation shows three further uses of groups. To opt only some routes into a layout, move them into a group with its own layout.js. To give one page a loading skeleton without affecting its siblings, put loading.tsx and the page inside a group such as (overview). And to partition an app into sections with completely different UI, create multiple root layouts.

Multiple Root Layouts

To create multiple root layouts, remove the top-level layout.js and add a layout.js with <html> and <body> inside each route group. This was built in a second scratch project (a (shop) and an (account) group, no app/layout.tsx):

Two root layouts, one per route group
// app/(shop)/layout.tsx
export default function ShopRootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body className="shop">{children}</body>
    </html>
  );
}

// app/(account)/layout.tsx
export default function AccountRootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body className="account">{children}</body>
    </html>
  );
}
Each route gets the body of its own root layout
$ for u in / /books /my-reviews; do echo $u; curl -s localhost:3203$u | grep -o '<body[^>]*>'; done
/
<body class="shop">
/books
<body class="shop">
/my-reviews
<body class="account">

The caveats from the Route Groups reference are real constraints, not style advice. Navigating between routes that use different root layouts triggers a full page load instead of a client-side navigation. Routes in different groups must not resolve to the same URL (the second build error above). And if you use multiple root layouts with no top-level layout.js, the home route / has to be defined inside one of the groups.

A root layout can also sit under a dynamic segment. The Bookshelf does this: its root layout is app/[locale]/layout.tsx, there is no app/layout.tsx, and the dynamic segments before the root layout are root parameters, which any Server Component can read with next/root-params.

app/[locale]/layout.tsx - the root layout of the Bookshelf, under a dynamic segment
import { notFound } from "next/navigation";
import { locales, isLocale } from "@/lib/i18n";

export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

export default async function RootLayout({
  children,
  modal,
  params,
}: LayoutProps<"/[locale]">) {
  const { locale } = await params;
  if (!isLocale(locale)) notFound();
  return (
    <html lang={locale}>
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}

The src Folder

Application code can live in an optional src folder, which separates it from the configuration files at the root. Move app (or pages) to src/app (or src/pages). The reference lists what has to stay put and what must follow:

  • public, package.json, next.config., tsconfig.json and .env files stay in the project root.

  • src/app or src/pages is ignored if app or pages exists in the root.

  • The Proxy (proxy.ts) goes inside src; so do instrumentation.ts and instrumentation-client.ts.

  • You will probably also move components and lib into src.

  • If a @/* path alias exists, update its target in tsconfig.json so that it includes src/.

  • If a tailwind.config.js file is used, add the /src prefix to its content paths.

The scratch Bookshelf was converted in a copy, moving app, components, lib, proxy.ts, instrumentation.ts and instrumentation-client.ts under src/ and pointing the alias at ./src/*. The build produced the identical route table, and the server printed the same register() line:

tsconfig.json when everything lives in src
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
Project layout with src
bookshelf/
├── public/
├── src/
│   ├── app/
│   ├── components/
│   ├── lib/
│   ├── proxy.ts
│   ├── instrumentation.ts
│   └── instrumentation-client.ts
├── next.config.ts
├── tsconfig.json
└── package.json

Source: src folder

Module Path Aliases

Next.js has built-in support for the paths and baseUrl options of tsconfig.json and jsconfig.json, so imports read @/components/button instead of ../../../components/button. create-next-app offers an import alias (default @/), and with --yes it is set up for you. The configuration that the scratch project uses, and that the generator writes, maps @/ to the project root with paths alone:

tsconfig.json - the default alias
{
  "compilerOptions": {
    "plugins": [{ "name": "next" }],
    "paths": {
      "@/*": ["./*"]
    }
  }
}
Using the alias from any depth
import { SiteHeader } from "@/components/ui/site-header";
import { getUser } from "@/lib/dal";

The reference also documents baseUrl (absolute imports relative to a folder) and several aliases whose targets are relative to baseUrl. This was built in the src copy: with baseUrl set to src/, a second alias and a bare absolute import both resolved, and the build passed.

tsconfig.json - baseUrl plus several aliases
{
  "compilerOptions": {
    "baseUrl": "src/",
    "paths": {
      "@/*": ["./*"],
      "@ui/*": ["components/ui/*"]
    }
  }
}
Imports that resolve with that configuration
import { SiteHeader } from "@ui/site-header"; // alias
import { requireUser } from "lib/dal";        // absolute import from baseUrl (src/)

Aliases are a TypeScript and bundler feature: they exist at compile time only, so they work in app, pages, proxy.ts and route files alike. The TypeScript plugin, typedRoutes and the PageProps, LayoutProps and RouteContext helpers are covered on the configuration and TypeScript page of this section.

Organisation Strategies

The official examples are a high-level overview of common strategies; the stated takeaway is to choose one that works for you and your team and to be consistent. components and lib are placeholders with no framework meaning; your project might use ui, utils, hooks or styles.

Table 7. Common strategies for project files
Strategy Layout Good when

Store project files outside app

Shared code in folders at the project root; app contains only routing files.

You want routing and implementation fully separate (the final Bookshelf layout).

Store project files in top-level folders inside app

Shared code in folders in the root of app.

You want everything under one folder.

Split project files by feature or route

Globally shared code in the root app folder, route-specific code in the segments that use it.

Features are owned by different people or move independently (colocation and private folders).

Whichever you pick, the rest of the structure is the same: private folders keep helpers away from the router, route groups divide the URL space into sections without changing it, and aliases make deep imports readable.

The Final Bookshelf Layout

The Bookshelf ends up with this shape. It uses the "outside `app`" strategy for shared code and colocated private folders for route-specific components. The layout below is the one built and run for this page; the files of the other pages (authentication, Server Actions, caching) slot into the same folders.

The reduced Bookshelf project (as built for this page)
bookshelf/
├── app/
│   ├── [locale]/
│   │   ├── layout.tsx                 # root layout (html/body), under a dynamic segment
│   │   ├── @modal/                    # parallel-route slot, rendered by the layout as `modal`
│   │   │   ├── default.tsx            # required fallback for the slot
│   │   │   └── (.)books/[id]/page.tsx # intercepting route: quick view of a book
│   │   ├── (shop)/                    # public area (group: not in the URL)
│   │   │   ├── layout.tsx
│   │   │   ├── page.tsx               # /en
│   │   │   ├── not-found.tsx
│   │   │   ├── _components/book-card.tsx
│   │   │   └── books/[id]/
│   │   │       ├── page.tsx
│   │   │       ├── loading.tsx
│   │   │       ├── error.tsx
│   │   │       └── opengraph-image.tsx
│   │   └── (account)/                 # signed-in area (group)
│   │       ├── layout.tsx             # calls requireUser()
│   │       └── my-reviews/page.tsx
│   ├── api/books/route.ts             # Route Handler: /api/books
│   ├── global-error.tsx
│   ├── robots.ts
│   └── sitemap.ts
├── components/ui/site-header.tsx      # shared UI, outside app
├── lib/
│   ├── dal.ts                         # Data Access Layer (server-only)
│   ├── books.ts
│   ├── i18n.ts
│   └── actions/reviews.ts             # Server Actions ("use server")
├── public/
├── proxy.ts                           # locale redirect
├── instrumentation.ts                 # server observability hook
├── instrumentation-client.ts          # client observability hook
├── next.config.ts
├── tsconfig.json                      # "@/*" alias
├── eslint.config.mjs
├── AGENTS.md                          # generated; CLAUDE.md contains "@AGENTS.md"
└── package.json

The data access layer and the Server Actions are the two files with an architectural role; both are plain modules that only Server Components and actions import. lib/dal.ts reads the session cookie through cookies() and is the single place that decides who the user is (the scratch version only checks that a cookie exists; the authentication page replaces it with a real session check):

lib/dal.ts - a server-only module
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

// Data Access Layer: the only place that reads the session
export const getUser = cache(async () => {
  const jar = await cookies();
  const id = jar.get("session")?.value;
  return id ? { id } : null;
});

export async function requireUser(locale: string) {
  const user = await getUser();
  if (!user) redirect(`/${locale}/sign-in`);
  return user;
}
lib/actions/reviews.ts - a file of Server Actions
"use server";

import { revalidatePath } from "next/cache";
import { getUser } from "@/lib/dal";

export async function addReview(formData: FormData) {
  const user = await getUser();
  if (!user) throw new Error("Not signed in");
  const bookId = String(formData.get("bookId"));
  const text = String(formData.get("text")).slice(0, 500);
  console.log("review", user.id, bookId, text.length);
  revalidatePath(`/[locale]/books/${bookId}`, "page");
}

The @modal slot and its default.tsx are the pieces of the layout that are easiest to forget. The root layout receives the slot as the modal prop; default.tsx returns null when no modal is active:

The slot’s default.tsx and its intercepting page
// app/[locale]/@modal/default.tsx
export default function Default() {
  return null;
}

// app/[locale]/@modal/(.)books/[id]/page.tsx
import { notFound } from "next/navigation";
import { getBook } from "@/lib/books";

export default async function BookModal({
  params,
}: PageProps<"/[locale]/books/[id]">) {
  const { id } = await params;
  const book = await getBook(id);
  if (!book) notFound();
  return <dialog open>Quick view: {book.title}</dialog>;
}

next build prints how every folder convention is rendered as a route. Note what is absent: no group names, no private folder, no colocated helper, no test file, no @modal entry (the intercepting route appears as /[locale]/(.)books/[id]), and global-error is not a route:

next build output (Next.js 16.3.8, previous caching model)
Route (app)
┌ ○ /_not-found
├   /[locale]
│ ├ ● /en
│ └ ● /es
├   /[locale]/_status
│ ├ ● /en/_status
│ └ ● /es/_status
├ ƒ /[locale]/(.)books/[id]
├ ƒ /[locale]/admin
├   /[locale]/books/[id]
│ ├ ● /en/books/1
│ ├ ● /en/books/2
│ ├ ● /en/books/3
│ └ ● [+3 more paths]
├ ƒ /[locale]/books/[id]/opengraph-image-1ksjp1
├ ƒ /[locale]/demo
├ ƒ /[locale]/my-reviews
├ ƒ /api/books
├ ○ /icon.svg
├ ○ /manifest.webmanifest
├ ○ /robots.txt
└ ○ /sitemap.xml


ƒ Proxy (Middleware)

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand

The legend ƒ Proxy (Middleware) still uses the old name in 16.3.8’s build output even though the file is proxy.ts. Building the same tree with cacheComponents: true fails: the build reports "Next.js encountered uncached or runtime data during prerendering" for /[locale]/my-reviews, because params and cookies() are read outside a Suspense boundary. The folders and files are unchanged; what changes under Cache Components is where the runtime-data reads may sit, which the caching and rendering pages explain.

The slot needs its default.tsx. The Next.js 16 upgrade guide says all parallel-route slots now require an explicit default.js and that builds fail without it. In the 16.3.8 scratch project the build did not fail when @modal/default.tsx was deleted, even when the slot also contained a normal page, but the symptom was worse than a build error because it surfaced only at runtime: every page under the [locale] layout answered 404 on a hard load.

Without @modal/default.tsx (build passed; requests failed)
$ for u in /en /en/books/1 /en/demo; do curl -s -o /dev/null -w "$u %{http_code}\n" localhost:3203$u; done
/en 404
/en/books/1 404
/en/demo 404

Always create default.tsx for every slot. The routing pages show how soft navigation keeps the slot’s active state and why a full page load needs this fallback.

The Pages Router Structure, for Comparison

In the Pages Router the file name in pages is the route, and every file there is a route. There are no layout files, no colocation and no private folders; special behaviour comes from reserved names with an underscore.

Table 8. The same ideas in both routers
Concern App Router Pages Router

Route files

page.tsx inside a folder

Any file in pages/; index.tsx for the folder’s own route

Document shell (<html>, <body>)

Root layout app/layout.tsx

pages/_document.tsx (server-only; Html, Head, Main, NextScript)

Shared wrapper around every page

Nested layout.tsx files

pages/_app.tsx (Component, pageProps)

Error and not-found UI

error, global-error, not-found

pages/_error.tsx, pages/404.tsx, pages/500.tsx

API endpoints

route.ts in app/ (Route Handlers)

Files in pages/api/

Head metadata

Metadata API and metadata files

next/head per page

Helper files next to routes

Safe (colocation, _private folders)

Not safe: any file in pages/ with a page extension is a route (pageExtensions can restrict which extensions count)

Data

Async Server Components and fetch

getStaticProps, getServerSideProps, getStaticPaths

A minimal Pages Router project was built to confirm the last point. It has _app, _document, an index page, a dynamic page with getStaticProps and getStaticPaths, an API route and a custom 404, plus a file pages/helper.tsx that was meant as a helper:

A Pages Router project
pages/
├── _app.tsx
├── _document.tsx
├── 404.tsx
├── index.tsx
├── helper.tsx           # a helper that nobody told Next.js is not a page
├── books/[id].tsx
└── api/books.ts
components/helper.tsx    # keep real helpers outside pages/
pages/_app.tsx and pages/_document.tsx
// pages/_app.tsx
import type { AppProps } from "next/app";

export default function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}

// pages/_document.tsx
import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html lang="en">
      <Head />
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}
pages/api/books.ts - the Pages Router counterpart of a Route Handler
import type { NextApiRequest, NextApiResponse } from "next";

export default function handler(_req: NextApiRequest, res: NextApiResponse) {
  res.status(200).json([{ id: "1", title: "Dune" }]);
}
Pages Router route table and requests (next build, next start)
Route (pages)
┌ ○ /
├   /_app
├ ○ /404
├ ƒ /api/books
├   /books/[id]
│ └ ● /books/1
└ ○ /helper

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses getStaticProps)
ƒ  (Dynamic)  server-rendered on demand

$ for u in / /books/1 /api/books /helper /nope; do curl -s -o /dev/null -w "$u %{http_code}\n" localhost:3203$u; done
/ 200
/books/1 200
/api/books 200
/helper 200
/nope 404

The helper became the public route /helper. A project can use both routers at once, which is how incremental adoption works, and the same shared folders (components, lib, public) serve both. The root layout replaces _app and _document when a page is moved to app.

What Changed Recently

Table 9. Changes to project structure, by Next.js version
Version Change

13.0

The app directory and the layout, page, loading, error and template conventions were introduced.

13.2

The Metadata API in app replaced the experimental head.js special file.

13.4

The App Router became stable.

15.0

instrumentation became stable and onRequestError was added; params and searchParams became promises.

15.1

forbidden.js and unauthorized.js introduced (experimental).

15.3

instrumentation-client introduced.

15.4

global-not-found.js introduced (experimental).

15.5

Middleware can use the Node.js runtime (stable).

16.0

middleware is deprecated and renamed proxy; the Proxy runs on Node.js and its function is named proxy. next lint was removed, so the project carries an eslint.config.mjs and runs the ESLint CLI. Parallel-route slots require a default.js. Turbopack is the default bundler. next dev writes to .next/dev.

16.2

create-next-app generates AGENTS.md, and version-matched documentation is bundled in the next package.

16.3

retry became stable in error.js (it was unstable_retry in 16.2). next dev maintains the AGENTS.md block (and CLAUDE.md) when an AI coding agent is detected. An experimental router-transition event can be enabled for instrumentation-client.

Where the Books Are Outdated

The two books consulted for this section, Real-World Next.js (Packt, 2022) and Practical Next.js for E-Commerce (Apress, 2023), describe the Pages Router with Next.js 12 and 13.2. Their project layouts are therefore the Pages Router ones. Nothing in them is wrong for that router, but several things have moved:

Table 10. Project-structure topics where the books are behind
Topic In the books (Pages Router, Next.js 12 / 13.2) Today (Next.js 16.3)

Route files and layout

pages/, pages/_app, pages/_document and pages/api; every file in pages is a route.

app/ with a root layout, nested layouts, route.ts; colocation is safe. The Pages Router is still supported.

Head and SEO files

next/head, the experimental head.js file (13.x) and a third-party SEO package.

The Metadata API plus the metadata file conventions (robots, sitemap, manifest, icon, opengraph-image).

Request interception

middleware.ts.

proxy.ts with an exported function named proxy (Node.js runtime).

Code organisation

Atomic-design style components and lib/utils folders at the root, away from pages/.

Still valid and now one of three official strategies. components and lib have no special meaning; colocation and private folders are the alternative.

src folder and alias

src/ with an @/* alias for imports.

Unchanged and still offered by create-next-app; just update paths when moving into src.

Build tooling files

Custom .babelrc and webpack configuration.

SWC and Turbopack (default in 16); a custom webpack config fails next build unless you pass a flag.

Linting

next lint and .eslintrc.

ESLint CLI with eslint.config.mjs (flat config).

New top-level files

(not covered)

instrumentation.ts, instrumentation-client.ts, mdx-components.tsx, AGENTS.md.