One of the most deliberate design decisions in Next.js is that the file system is the framework. Routes, layouts, loading states, error boundaries, API endpoints — all of these are not registered in a configuration file or declared in code. They emerge from the structure of the project itself. Place a file named page.tsx inside a folder called dashboard, and a /dashboard route exists. Place a loading.tsx alongside it, and a loading state for that route exists. The file system is not just where the code lives; it is the instruction set the framework reads.
This makes the project structure worth understanding deeply, because every file placement is a decision with consequences.
The Shape of a Next.js Project
At the top level, a Next.js project separates two concerns: configuration and application code.
my-app/
├── app/ ← Application code — routes, layouts, API handlers
├── public/ ← Static assets served at the root URL
├── next.config.ts ← Next.js configuration
├── package.json
├── tsconfig.json
└── eslint.config.mjs
Everything inside app/ is where an application actually lives. Everything outside of it is configuration and tooling. This clean separation means the root directory stays uncluttered even as the application grows.
The app/ Directory
The app/ directory is the App Router. It is where routes are defined, layouts are composed, and server-side logic lives alongside the components that use it. The folder structure inside app/ directly maps to URL paths — nesting folders nests URL segments. This relationship is the heart of how Next.js routing works, and it is covered in full in the routing topics that follow.
The public/ Directory
The public/ directory stores static assets — images, fonts, icons, and any other file that should be served directly to the browser without processing. Files here are accessible from the root of the site. A file placed at public/logo.png is reachable at https://example.com/logo.png. No import or configuration is needed; the public/ folder is simply a direct window to the web server.
Top-Level Files
Several files at the root of a Next.js project serve specific, framework-level purposes.
| File | Purpose |
|---|---|
next.config.ts | Configuration for Next.js itself — redirects, headers, build settings |
package.json | Project dependencies and npm scripts |
tsconfig.json | TypeScript compiler configuration |
eslint.config.mjs | Linting rules |
.env | Environment variables shared across all environments |
.env.local | Local environment variables, not committed to version control |
.env.development | Environment variables loaded in development only |
.env.production | Environment variables loaded in production only |
next-env.d.ts | Auto-generated TypeScript types for Next.js — never edit this manually |
Of these, next.config.ts is worth a closer look. Most applications need very little in it initially, but it is the canonical place to extend or override Next.js's default behaviour. Redirects for old URLs, custom HTTP headers for security, image domain allowlists, and build-time feature flags all live here. It exports a configuration object:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// configuration options here
};
export default nextConfig;
File Conventions Inside app/
Next.js recognises a specific set of filenames inside the app/ directory and treats each one as a distinct piece of the application's behaviour. These are not arbitrary conventions — each filename corresponds to a specific role in how a route is rendered, how errors are handled, and how the user experiences transitions between states.
| File | Extensions | Purpose |
|---|---|---|
layout | .tsx .jsx .js | Shared UI wrapping a route segment and its children |
page | .tsx .jsx .js | The unique UI for a route — makes the route publicly accessible |
loading | .tsx .jsx .js | Loading skeleton shown while the page or its data is being fetched |
error | .tsx .jsx .js | Error boundary for a route segment — catches errors within it |
not-found | .tsx .jsx .js | UI shown when the route segment throws a "not found" response |
global-error | .tsx .jsx .js | Error boundary for the root layout — the last line of defence |
route | .ts .js | API endpoint — handles HTTP requests, returns data |
template | .tsx .jsx .js | Like a layout, but re-mounted on every navigation |
default | .tsx .jsx .js | Fallback UI for parallel routes |
The most important three for everyday work are page, layout, and route. The others — loading, error, not-found — exist to handle the conditions that pages do not explicitly manage, and they give the framework a place to put behaviour that would otherwise require boilerplate in every component.
page.tsx
A page.tsx file is what makes a route exist and be publicly accessible. Without it, a folder in the app/ directory defines a URL segment but serves nothing. With it, visiting that URL renders the component the file exports.
// app/blog/page.tsx
export default function BlogPage() {
return <h1>The Blog</h1>;
}
This file alone creates the /blog route. Nothing else is required.
layout.tsx
A layout.tsx file wraps every page within its directory and all of its subdirectories. It receives the current page as a children prop and renders it inside whatever surrounding structure the layout defines — a navigation bar, a sidebar, a footer, consistent padding.
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
The root layout at app/layout.tsx is required and must include the <html> and <body> tags. Every other page in the application renders inside this structure. Layouts do not re-mount when the user navigates between routes — they persist, which is what allows elements like navigation bars and sidebars to remain stable while the page content changes beneath them.
route.ts
A route.ts file defines an API endpoint. It exports named functions corresponding to HTTP methods — GET, POST, PUT, DELETE, PATCH — and each function receives a request and returns a response.
// app/api/users/route.ts
export async function GET() {
const users = await fetchUsers();
return Response.json(users);
}
This creates a /api/users endpoint that responds to GET requests. Route Handlers are covered in full in their own dedicated topic.
How Folders Map to URLs
The mapping from folder structure to URL is direct and hierarchical. Each folder name becomes a URL segment. Nesting folders nests segments.
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── authors/
│ └── page.tsx → /blog/authors
A route is only publicly accessible when a page.tsx or route.ts file exists within the folder. A folder without either file defines part of the URL hierarchy but serves nothing. This matters for colocation — component files, utility functions, and helper code can live inside app/ folders without accidentally becoming routes.
Special Folder Conventions
Beyond the basic file-to-URL mapping, Next.js recognises a few special folder naming patterns that change how a directory behaves in the routing system.
Dynamic Segments — [folder]
A folder name wrapped in square brackets becomes a dynamic route segment — a placeholder that matches any value in the URL at that position. The matched value is passed to the page as a params prop.
app/blog/[slug]/page.tsx → /blog/my-first-post
→ /blog/understanding-react
→ /blog/any-slug-here
// app/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <h1>Post: {slug}</h1>;
}
Dynamic segments are what make routes like /users/42 or /products/running-shoes possible without defining a separate route for every possible value.
Catch-All Segments — [...folder]
A folder named with three dots before the segment name captures all remaining URL segments into an array.
app/docs/[...slug]/page.tsx → /docs/getting-started
→ /docs/api/reference/hooks
→ /docs/anything/deeply/nested
The params.slug value is an array of the matched segments: ['api', 'reference', 'hooks'] for the second example above.
Optional Catch-All — [[...folder]]
Wrapping the catch-all in double brackets makes it optional — the route matches both the base path and any sub-paths.
app/docs/[[...slug]]/page.tsx → /docs
→ /docs/getting-started
→ /docs/api/reference/hooks
Route Groups — (folder)
A folder name wrapped in parentheses creates a route group. The folder is excluded from the URL entirely — it exists only to organise files or to scope a shared layout to a subset of routes, without the group name appearing in any URL.
app/
├── (marketing)/
│ ├── page.tsx → /
│ └── about/
│ └── page.tsx → /about
└── (dashboard)/
└── settings/
└── page.tsx → /settings
Both sections share the same URL namespace, but they can have completely different layouts defined within their respective groups. The parentheses are purely organisational — invisible to the URL, visible to the developer.
Private Folders — _folder
A folder prefixed with an underscore is excluded from the routing system entirely. Next.js will never treat it or its contents as routes, regardless of whether page.tsx files exist inside it.
app/
├── blog/
│ ├── _components/
│ │ └── PostCard.tsx ← not a route
│ ├── _lib/
│ │ └── helpers.ts ← not a route
│ └── page.tsx → /blog
Private folders are the idiomatic place to colocate components, utilities, and helper code that belong to a specific part of the application without cluttering the routes or risking accidental exposure.
Component Hierarchy Within a Route
When a route is matched, Next.js does not render just the page.tsx file. It builds a hierarchy of components, each wrapping the next, in a specific order:
layout.tsx
└── template.tsx
└── error.tsx (error boundary)
└── loading.tsx (suspense boundary)
└── not-found.tsx (error boundary)
└── page.tsx
This hierarchy is rendered at every level of nesting. A route at /blog/authors nests the components from app/layout.tsx, then app/blog/layout.tsx if it exists, then app/blog/authors/layout.tsx if that exists, and finally app/blog/authors/page.tsx at the centre. The result is that layouts, loading states, and error boundaries at any level in the hierarchy apply to all routes beneath them automatically.
Organising the Project
Next.js is deliberately unopinionated about how code inside the project is organised, beyond the conventions described above. There is no single correct approach — different teams and different project sizes call for different structures. But three patterns are commonly used and worth understanding.
Files outside app/, organised by type. Components, hooks, utility functions, and other shared code live in top-level folders alongside app/ — a components/ folder, a lib/ folder, a hooks/ folder. The app/ directory contains only routing files. This is clean and simple for smaller projects.
my-app/
├── app/
├── components/
├── lib/
└── hooks/
Files inside app/, co-located with routes. All code lives within app/, either at the root for shared things or inside specific route folders for things that only that route needs. Private folders (_folder) keep non-route files from being exposed. This maximises cohesion — a route's page, its components, and its data-fetching logic all live together.
my-app/
└── app/
├── _components/ ← shared
├── _lib/ ← shared
└── blog/
├── _components/ ← blog-specific
└── page.tsx
Files split by feature. Shared code at the app/ root, feature-specific code co-located within route segments. This scales well for larger applications where different routes have significantly different concerns.
The consistent principle across all three approaches is this: whichever structure is chosen, it should be applied consistently across the entire project. Next.js enforces the file conventions; everything else is a team decision.