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
pageor aroutefile (or a metadata route such assitemap.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,_folderis invisible to routing,@slotis a named slot and(.)folderintercepts a route.
Top-Level Folders
| Folder | Purpose | Notes |
|---|---|---|
|
The App Router: file-system routes, layouts, Server Components and Route Handlers. |
The recommended router for new projects. A root layout (with |
|
The Pages Router: one file per route, |
Still supported; both routers can live in the same project, which is how incremental migration works. |
|
Static assets served from the base URL: |
Next.js cannot safely cache these files, so it sends |
|
Optional folder that holds |
|
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:
$ 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.
| File | Purpose | Notes |
|---|---|---|
|
Configuration of Next.js itself. |
|
|
Dependencies and scripts ( |
Minimum manual install: |
|
The Proxy: code that runs on the server before a request is completed (rewrite, redirect, header changes). |
Same level as |
|
Server-side observability hook: |
Root, or inside |
|
Client-side code that runs after the HTML is loaded and before React hydration starts; may export
|
Root, or inside |
|
Required to use |
Root, or inside |
|
Environment variables. |
Should not be tracked by version control (at least |
|
ESLint flat configuration. |
Linting runs through the ESLint CLI; |
|
TypeScript or JavaScript configuration, including |
Next.js adds the |
|
Generated TypeScript declarations for Next.js. |
Generated; should not be tracked by version control. |
|
Files and folders Git ignores. |
Must cover |
|
Instructions for AI coding agents. The Next.js block points agents at the version-matched documentation bundled in
|
Generated by |
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 - 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:
$ 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.
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
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;
}
Source: mdx-components.js, MDX
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:
<!-- 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 -->
Source: AI Coding Agents
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
| File | Extensions | What it does |
|---|---|---|
|
|
Shared UI that wraps the segment and its children. Preserves state and does not re-render on navigation between
child routes. The root layout ( |
|
|
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 |
|
|
An instant loading state: Next.js wraps the segment’s |
|
|
UI rendered when |
|
|
Experimental (since 15.4, behind |
|
|
A React error boundary (a Client Component) for the segment’s |
|
|
Replaces the root layout when an error escapes it; must define its own |
|
|
A Route Handler: an endpoint exporting |
|
|
Like a layout, but a new instance is mounted for each child on navigation, so state is reset and effects re-run. |
|
|
Fallback for a parallel-route slot (and the implicit |
|
|
UI for the |
|
|
UI for the |
Sources: layout.js, page.js, loading.js, not-found.js, error.js, route.js, template.js, default.js, forbidden.js, unauthorized.js
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().
// 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>;
}
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):
$ 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
Sources: error.js, forbidden.js, forbidden(), authInterrupts
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:
import { listBooks } from "@/lib/books";
export async function GET() {
return Response.json(await listBooks());
}
$ 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.
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>
);
}
$ 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.
| Pattern | Example | Meaning |
|---|---|---|
|
|
A route segment. |
|
|
A dynamic segment; the value arrives in |
|
|
Catch-all segment. |
|
|
Optional catch-all segment. |
|
|
Route group: organisation only, omitted from the URL. |
|
|
Private folder: the folder and all its subfolders are opted out of routing. Use |
|
|
Named slot, rendered by the parent layout as a prop. |
|
|
Intercept a route at the same level. |
|
|
Intercept a route one level up (segments, not folders: groups and slots do not count). |
|
two levels up |
Intercept two levels up. |
|
from the root |
Intercept from the root. |
Sources: Dynamic Segments, Parallel Routes, Intercepting Routes
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.
| File | Static extensions | Generated extensions | Valid location |
|---|---|---|---|
|
|
(none) |
Root of |
|
|
|
Any segment of |
|
|
|
Any segment of |
|
|
|
Any route segment (alt text in an |
|
|
|
Any route segment. |
|
|
|
Root of |
|
|
|
Root of |
|
|
|
Root of |
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
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" };
}
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,
);
}
$ 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.
| Convention | Where it is covered |
|---|---|
|
|
|
|
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 ( |
Source: File-system conventions
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.
Two consequences follow from the nesting, and both are easy to get wrong:
-
error.jssits inside the layout and template of its own segment, so it cannot catch their errors. An error in the root layout needsglobal-error.js, which replaces the root layout while active. -
loading.jswraps 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:
// 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>;
}
<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.
Source: Component hierarchy
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:
The Bookshelf book page keeps a helper and a test next to page.tsx:
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
// A colocated helper: sits next to page.tsx but is not routable
export const formatTitle = (t: string) => t.toUpperCase();
$ 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):
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:
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
$ 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.
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:
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):
// 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>
);
}
$ 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.
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>
);
}
Sources: Route Groups, Root layout, next/root-params
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.jsonand.envfiles stay in the project root. -
src/apporsrc/pagesis ignored ifapporpagesexists in the root. -
The Proxy (
proxy.ts) goes insidesrc; so doinstrumentation.tsandinstrumentation-client.ts. -
You will probably also move
componentsandlibintosrc. -
If a
@/*path alias exists, update its target intsconfig.jsonso that it includessrc/. -
If a
tailwind.config.jsfile is used, add the/srcprefix to itscontentpaths.
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:
{
"compilerOptions": {
"paths": {
"@/*": ["./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:
{
"compilerOptions": {
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
}
}
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.
{
"compilerOptions": {
"baseUrl": "src/",
"paths": {
"@/*": ["./*"],
"@ui/*": ["components/ui/*"]
}
}
}
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.
| Strategy | Layout | Good when |
|---|---|---|
Store project files outside |
Shared code in folders at the project root; |
You want routing and implementation fully separate (the final Bookshelf layout). |
Store project files in top-level folders inside |
Shared code in folders in the root of |
You want everything under one folder. |
Split project files by feature or route |
Globally shared code in the root |
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.
Source: Project structure examples
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.
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):
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;
}
"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:
// 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:
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.
$ 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.
| Concern | App Router | Pages Router |
|---|---|---|
Route files |
|
Any file in |
Document shell ( |
Root layout |
|
Shared wrapper around every page |
Nested |
|
Error and not-found UI |
|
|
API endpoints |
|
Files in |
Head metadata |
Metadata API and metadata files |
|
Helper files next to routes |
Safe (colocation, |
Not safe: any file in |
Data |
Async Server Components and |
|
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:
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
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>
);
}
import type { NextApiRequest, NextApiResponse } from "next";
export default function handler(_req: NextApiRequest, res: NextApiResponse) {
res.status(200).json([{ id: "1", title: "Dune" }]);
}
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
| Version | Change |
|---|---|
13.0 |
The |
13.2 |
The Metadata API in |
13.4 |
The App Router became stable. |
15.0 |
|
15.1 |
|
15.3 |
|
15.4 |
|
15.5 |
Middleware can use the Node.js runtime (stable). |
16.0 |
|
16.2 |
|
16.3 |
|
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:
| Topic | In the books (Pages Router, Next.js 12 / 13.2) | Today (Next.js 16.3) |
|---|---|---|
Route files and layout |
|
|
Head and SEO files |
|
The Metadata API plus the metadata file conventions ( |
Request interception |
|
|
Code organisation |
Atomic-design style |
Still valid and now one of three official strategies. |
|
|
Unchanged and still offered by |
Build tooling files |
Custom |
SWC and Turbopack (default in 16); a custom |
Linting |
|
ESLint CLI with |
New top-level files |
(not covered) |
|
Source for the book editions: Real-World Next.js (Packt) and Practical Next.js for E-Commerce (Springer)
References
-
Next.js 16, 16.2, 16.3, 13.2, 13.4