📁 Chapter 2: Project Structure & Configuration
Every special filename Next.js reserves, how to organize a real codebase without fighting the router, and the config options that actually matter.
📖 Plain English Explanation
Most frameworks ask you to register your routes somewhere. Next.js reads your folders instead.
That trade is powerful but has a catch: certain filenames are reserved. A file called page.tsx is not just a file — it is a route. A file called loading.tsx is a loading state. If you name a helper file route.ts by accident, Next.js will try to serve it as an API endpoint.
So the first thing to learn is the reserved vocabulary. There are about fifteen words. Once you know them, every Next.js codebase in the world looks familiar.
The second thing to learn is how to put your own files — components, utilities, types — next to your routes without accidentally creating URLs. Next.js has explicit tools for that, and this chapter covers all of them.
🗂️ The reserved filenames
Inside app/, these names have meaning. Everything else is just a file.
| File | What it does |
|---|---|
layout | Shared UI that wraps a segment and all its children. Persists across navigation. |
page | The UI for a route. A folder is only a route if it has a page. |
loading | Instant loading UI, backed by a Suspense boundary |
not-found | UI for notFound() and unmatched URLs |
error | Error boundary for a segment |
global-error | Error boundary for the root layout itself |
forbidden | UI for forbidden() — 403 |
unauthorized | UI for unauthorized() — 401 |
route | An API endpoint (cannot coexist with page in the same folder) |
template | Like layout, but remounts on every navigation |
default | Fallback UI for a parallel route slot |
proxy | Runs before a request is completed (root-level only) |
instrumentation | Server startup hook — observability, monitoring |
instrumentation-client | Browser startup hook |
mdx-components | Required to use MDX |
Each accepts .js, .jsx, .ts, or .tsx.
Plus a set of metadata files, which are covered in Chapter 19:
favicon.ico icon.png apple-icon.png
opengraph-image.tsx twitter-image.tsx
robots.txt sitemap.ts manifest.json
The rule that trips everyone up
A folder becomes a URL only when it contains a
pagefile.
app/
├── page.tsx → / ✅ route
├── dashboard/
│ ├── layout.tsx → (no route — just wraps children)
│ ├── page.tsx → /dashboard ✅ route
│ └── settings/
│ └── page.tsx → /dashboard/settings ✅ route
└── lib/
└── utils.ts → (nothing — no page file)
app/lib/utils.ts is not a route. It's safe to put helper files inside app/.
🧩 How segments nest
Every folder is a route segment. Layouts nest automatically, parent wrapping child:
app/
├── layout.tsx ← root layout
├── page.tsx
└── shop/
├── layout.tsx ← shop layout
├── page.tsx
└── [category]/
├── layout.tsx ← category layout
└── page.tsx
Visiting /shop/shoes renders:
RootLayout
└── ShopLayout
└── CategoryLayout
└── CategoryPage
Layouts do not re-render when you navigate between their children. Navigating from /shop/shoes to /shop/hats keeps ShopLayout mounted — scroll position, state, and running videos survive. This is the single biggest UX advantage of the App Router, and Chapter 3 goes deeper.
🔒 Organizing without creating routes
You have four tools. Use them deliberately.
1. Colocation — just put files in the folder
Any file without a reserved name is inert. This is the default and usually the right answer:
app/dashboard/
├── page.tsx
├── layout.tsx
├── DashboardChart.tsx ← component, not a route
├── formatCurrency.ts ← helper, not a route
└── types.ts ← types, not a route
2. Private folders — prefix with _
A folder starting with an underscore is completely excluded from routing, including everything inside it:
app/
├── _components/ ← never a route
│ ├── Button.tsx
│ └── Card.tsx
├── _lib/
│ └── db.ts
└── dashboard/
└── page.tsx → /dashboard
Use this when a folder would otherwise look like a route and confuse people.
To route a URL that literally starts with an underscore, use
%5F(the URL-encoded underscore) in the folder name.
3. Route groups — wrap in (parentheses)
A folder in parentheses organizes files without adding a URL segment:
app/
├── (marketing)/
│ ├── layout.tsx ← applies to about + pricing only
│ ├── about/page.tsx → /about (NOT /marketing/about)
│ └── pricing/page.tsx → /pricing
└── (app)/
├── layout.tsx ← a totally different shell
├── dashboard/page.tsx → /dashboard
└── settings/page.tsx → /settings
This is how you give a marketing site and a logged-in app completely different layouts at the same URL level. Chapter 6 covers the patterns in full.
4. src/ — move the whole app down one level
If you prefer application code separated from config files:
my-app/
├── src/
│ ├── app/
│ └── components/
├── public/
├── next.config.ts
└── package.json
public/ and config files stay at the root. Everything else behaves identically. Purely a preference — pick one and be consistent.
🧱 A structure that scales
Small apps can colocate everything. Once you pass a dozen routes, most teams land on something like this:
my-app/
├── app/
│ ├── (marketing)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── pricing/page.tsx
│ ├── (dashboard)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── settings/page.tsx
│ ├── api/
│ │ └── webhooks/stripe/route.ts
│ ├── layout.tsx
│ ├── globals.css
│ └── proxy.ts
├── components/
│ ├── ui/ ← generic, reusable (Button, Input)
│ └── features/ ← domain-specific (InvoiceTable)
├── lib/
│ ├── db.ts
│ ├── auth.ts
│ └── utils.ts
├── hooks/
│ └── use-media-query.ts
├── types/
│ └── index.ts
├── public/
│ └── logo.svg
├── next.config.ts
└── tsconfig.json
Two guidelines that hold up over time:
- Route-specific code goes next to the route. A component used by exactly one page belongs in that page's folder.
- Shared code moves up. The moment a second route imports it, promote it to
components/orlib/.
Resist building a components/ folder with 200 files in it on day one.
📤 public/ — static assets
Files in public/ are served from the root URL:
public/
├── logo.svg → /logo.svg
├── favicon.ico → /favicon.ico
└── docs/guide.pdf → /docs/guide.pdf
// app/page.tsx
import Image from 'next/image'
export default function Page() {
return <Image src="/logo.svg" alt="Logo" width={120} height={40} />
}
// app/page.js
import Image from 'next/image'
export default function Page() {
return <Image src="/logo.svg" alt="Logo" width={120} height={40} />
}
Note the path is /logo.svg, not /public/logo.svg. Files here are not processed, hashed, or optimized by the bundler — they're copied as-is.
🎛️ next.config.ts
Next.js 16 ships first-class TypeScript config support. Use it — the autocomplete alone is worth it.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// your options here
}
export default nextConfig
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// your options here
}
module.exports = nextConfig
There are well over a hundred options. Here are the ones you'll actually reach for, grouped by why you'd want them.
Type-safe routes
// next.config.ts
const nextConfig: NextConfig = {
typedRoutes: true,
}
Now <Link href="/dashbaord"> is a compile error, not a 404 discovered in production.
Remote images
// next.config.ts
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.unsplash.com' },
{ protocol: 'https', hostname: '**.mycdn.com', pathname: '/assets/**' },
],
},
}
⚠️ Changed in Next.js 16
images.domainsis deprecated. Useimages.remotePatterns, which lets you constrain protocol, path, and port instead of trusting a whole hostname.js// ❌ deprecated images: { domains: ['example.com'] } // ✅ current images: { remotePatterns: [{ protocol: 'https', hostname: 'example.com' }] }
Chapter 20 covers the other image defaults that changed in 16.
Cache Components (Partial Prerendering)
// next.config.ts
const nextConfig: NextConfig = {
cacheComponents: true,
}
⚠️ Changed in Next.js 16
experimental.ppr,experimental.dynamicIO, andexperimental.useCachewere all removed and replaced by the single top-levelcacheComponentsflag. This is not a rename — the underlying model changed. Chapter 16 covers it properly.
React Compiler
// next.config.ts
const nextConfig: NextConfig = {
reactCompiler: true,
}
Automatic memoization — no more hand-written useMemo/useCallback. Stable in Next.js 16, but off by default because it relies on Babel and slows builds. Requires npm install -D babel-plugin-react-compiler.
Redirects and rewrites
// next.config.ts
const nextConfig: NextConfig = {
async redirects() {
return [
{ source: '/old-blog/:slug', destination: '/blog/:slug', permanent: true },
]
},
async rewrites() {
return [
{ source: '/docs/:path*', destination: 'https://docs.example.com/:path*' },
]
},
}
A redirect changes the URL in the browser. A rewrite does not — the user sees /docs/intro while the content comes from elsewhere.
Security headers
// next.config.ts
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
},
]
},
}
Deployment output
// next.config.ts
const nextConfig: NextConfig = {
output: 'standalone', // self-contained Node server, ideal for Docker
// output: 'export', // fully static HTML, no server at all
}
Turbopack
// next.config.ts
const nextConfig: NextConfig = {
turbopack: {
resolveAlias: {
'@ui': './components/ui',
},
},
}
⚠️ Changed in Next.js 16
experimental.turbopackgraduated to a top-levelturbopackkey.js// ❌ Next.js 15 experimental: { turbopack: { /* ... */ } } // ✅ Next.js 16 turbopack: { /* ... */ }
Bundling controls
// next.config.ts
const nextConfig: NextConfig = {
// Tree-shake big barrel-file libraries
optimizePackageImports: ['lodash', 'date-fns', '@mui/icons-material'],
// Keep these out of the bundle; require() them at runtime on the server
serverExternalPackages: ['sharp', 'pdfkit'],
// Compile packages that ship untranspiled source
transpilePackages: ['@my-org/ui'],
}
Options that no longer exist
⚠️ Changed in Next.js 16
These were removed. If you see them in a config, it's from an older version:
Removed Replacement serverRuntimeConfigprocess.env.MY_VARin Server ComponentspublicRuntimeConfigprocess.env.NEXT_PUBLIC_MY_VAReslint: {}run ESLint yourself ( next lintis gone)amp: {}AMP support removed entirely experimental.dynamicIOcacheComponents: trueexperimental.useCachecacheComponents: truedevIndicators.buildActivity(indicator still exists, options removed)
🔐 Environment variables
Next.js reads .env files automatically, in this precedence order:
.env.local ← highest priority, gitignored, your secrets
.env.development ← loaded during next dev
.env.production ← loaded during next build / next start
.env ← defaults, committed
The one rule
Variables are server-only unless prefixed with NEXT_PUBLIC_.
# .env.local
DATABASE_URL="postgresql://localhost:5432/mydb" # server only — never sent to browser
STRIPE_SECRET_KEY="sk_live_..." # server only
NEXT_PUBLIC_SITE_URL="https://example.com" # inlined into browser bundles
// app/page.tsx — Server Component, has access to everything
import { db } from '@/lib/db'
export default async function Page() {
const url = process.env.DATABASE_URL // ✅ works
const users = await db.query(url, '...')
return <div>{users.length} users</div>
}
// app/components/Analytics.tsx — Client Component
'use client'
export function Analytics() {
const site = process.env.NEXT_PUBLIC_SITE_URL // ✅ works
const secret = process.env.STRIPE_SECRET_KEY // ❌ undefined — and that's the point
return <script data-site={site} />
}
NEXT_PUBLIC_ values are inlined at build time, baked into the JavaScript bundle. Changing one requires a rebuild.
Reading env vars at runtime
If you self-host and want to change a variable without rebuilding, force the read to happen per-request with connection():
// app/page.tsx
import { connection } from 'next/server'
export default async function Page() {
await connection() // opt out of static prerendering
const config = process.env.RUNTIME_CONFIG // read at request time, not build time
return <p>{config}</p>
}
// app/page.js
import { connection } from 'next/server'
export default async function Page() {
await connection()
const config = process.env.RUNTIME_CONFIG
return <p>{config}</p>
}
🏷️ next typegen and the props helpers
Next.js generates global type helpers for route props. Run:
npx next typegen
You get three globals, no import needed:
// app/blog/[slug]/page.tsx
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
const { page } = await props.searchParams
return <h1>{slug}</h1>
}
// app/blog/layout.tsx
export default async function Layout(props: LayoutProps<'/blog'>) {
return <section>{props.children}</section>
}
// app/api/posts/[id]/route.ts
export async function GET(request: Request, context: RouteContext<'/api/posts/[id]'>) {
const { id } = await context.params
return Response.json({ id })
}
The route string is checked against your actual folder structure, so PageProps<'/blog/[slugg]'> fails to compile. next dev and next build run typegen for you; you only run it manually in CI or after adding routes while the dev server is stopped.
📐 The import alias
create-next-app sets this up:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./*"]
}
}
}
Which turns this:
import { Button } from '../../../components/ui/Button'
...into this:
import { Button } from '@/components/ui/Button'
Use it everywhere. Relative imports across more than one level become unmaintainable the first time you move a folder.
⚠️ Common Pitfalls
1. A folder with no page file, wondering why the route 404s
app/dashboard/
└── layout.tsx ← /dashboard returns 404
Fix: add page.tsx. A layout alone renders nothing.
2. page.tsx and route.ts in the same folder
app/api/users/
├── page.tsx ← ❌ conflict
└── route.ts ← ❌ conflict
A segment can serve a UI page or an API endpoint, never both. Fix: move one.
3. Expecting a secret in a Client Component
'use client'
console.log(process.env.API_SECRET) // undefined
This is a security feature, not a bug. Secrets must never reach the browser. Fix: fetch the data in a Server Component and pass the result down as props, or call a Server Action.
4. Committing .env.local
# .gitignore — verify this line exists
.env*
create-next-app adds it. If you migrated a project by hand, check.
5. Assuming _components is required
Private folders are a tool, not a convention you must follow. app/dashboard/Chart.tsx is already safe — a .tsx file with a non-reserved name is never a route. Use _ only when the folder name would be misleading.
6. Editing next.config.ts and expecting hot reload
Config changes require a full restart of next dev. Next.js will usually tell you, but if a change seems ignored, restart first before debugging.
🎯 When & Why to Use Each Organization Tool
Colocation → default; component used by exactly one route
Private (_) → a folder that would otherwise look like a route
Route groups () → different layouts, or grouping without a URL segment
src/ → team preference for separating app code from config
top-level dirs → anything imported by two or more routes
Decision flow when you don't know where a file goes:
Is it used by exactly one route?
├── Yes → put it in that route's folder
└── No → Is it generic UI?
├── Yes → components/ui/
└── No → Is it logic/data?
├── Yes → lib/
└── No → components/features/
🏋️ Mini Practice Problems
Problem 1: Which of these are routes?
app/
├── page.tsx
├── utils.ts
├── (auth)/
│ ├── login/page.tsx
│ └── register/page.tsx
├── _internal/
│ └── admin/page.tsx
├── blog/
│ └── layout.tsx
└── api/
└── health/route.ts
List every URL this app serves.
Problem 2: Fix the config
This config is written for Next.js 15. Bring it to 16:
// next.config.js
module.exports = {
images: { domains: ['cdn.example.com'] },
experimental: {
ppr: true,
turbopack: { resolveAlias: { '@ui': './ui' } },
},
eslint: { ignoreDuringBuilds: true },
publicRuntimeConfig: { apiUrl: '/api' },
}
Problem 3: Env var safety audit
Which of these leak a secret to the browser?
// A
export default async function Page() {
const key = process.env.STRIPE_SECRET
return <div>Loaded</div>
}
// B
export default async function Page() {
const key = process.env.STRIPE_SECRET
return <ClientWidget apiKey={key} />
}
// C
'use client'
export function Widget() {
return <div>{process.env.NEXT_PUBLIC_ANALYTICS_ID}</div>
}
Problem 4: Design the tree
Sketch the app/ folder for a site with:
- A public marketing site (
/,/pricing,/about) with one layout - A logged-in dashboard (
/dashboard,/dashboard/billing) with a different layout, including a sidebar that must not remount when navigating between the two dashboard pages - A Stripe webhook endpoint
💼 Interview Notes
Common Questions
Q: How does Next.js decide what's a route?
A folder inside app/ becomes a URL segment; the segment is servable only if the folder contains a page (UI) or route (API) file. Folders in (parentheses) add no segment. Folders starting with _ are skipped entirely.
Q: What's the difference between a route group and a private folder?
A route group (name) still routes its children, it just doesn't contribute a URL segment — it exists to share a layout or organize. A private folder _name removes itself and everything under it from routing completely.
Q: Why can't you have page.tsx and route.ts in the same segment?
They both claim the same URL. One returns HTML, the other returns a Response. Next.js can't pick, so it errors at build time.
Q: How do environment variables work in Next.js?
Everything in .env is available on the server. Only NEXT_PUBLIC_-prefixed variables are inlined into client bundles at build time. To read a variable at request time instead of build time, call await connection() first to opt out of prerendering.
Q: What does typedRoutes do?
It generates a union type of every valid route in your app and applies it to href on <Link> and to router.push(). Typos become compile errors instead of runtime 404s.
🏢 Asked at Companies
- Vercel: "How would you structure an app with both a public site and an authenticated dashboard needing different shells?"
- Airbnb: "Where do you draw the line between colocating a component and promoting it to a shared folder?"
- Stripe: "A junior engineer put an API key in a Client Component. What happens, and how do you prevent it in review?"
- Notion: "Explain how layout nesting affects what re-renders during navigation."
📊 Visual Memory Aid
app/ FOLDER SEMANTICS
folder/ → adds "/folder" to the URL
(folder)/ → adds NOTHING to the URL (route group)
_folder/ → removed from routing (private)
[folder]/ → dynamic segment /:folder
[...folder]/ → catch-all /a/b/c
[[...folder]]/ → optional catch-all / or /a/b/c
@folder/ → parallel route slot
(.)folder/ → intercepting route
FILE PRECEDENCE IN A SEGMENT
layout ──► wraps everything below, persists
└── template ──► wraps, but remounts each nav
└── error ──► catches errors below
└── loading ──► Suspense fallback
└── not-found
└── page ◄── your actual UI
ENV VAR VISIBILITY
DATABASE_URL │ server ✅ browser ❌
NEXT_PUBLIC_API_URL │ server ✅ browser ✅ (inlined at build)
🎯 Key Takeaways
- A folder is a route only if it contains
pageorroute. Everything else inapp/is just a file you can safely colocate. - Learn the fifteen reserved filenames. They are the entire vocabulary of App Router structure, and they're the same in every Next.js codebase.
- Use route groups
()for layout boundaries, private folders_for clarity. Colocation is the default — don't over-organize on day one. NEXT_PUBLIC_is the only thing separating a secret from the browser. Everything else stays on the server, and that's enforced, not conventional.- Several config options were removed in Next.js 16 —
serverRuntimeConfig,eslint,amp,experimental.ppr/dynamicIO/useCache. If a config you copied has them, it's from an older version.
Next Chapter: Layouts & Pages →
Practice: Take the app you scaffolded in Chapter 1 and restructure it into two route groups — (marketing) and (app) — each with its own layout. Confirm that /about and /dashboard render with different shells while sharing the root layout.