Next.js App Router Routing Is Just Files in the Right Folders
Thoroughly Understanding App Router Convention-Based Routing: Put Files in the Right Place, and Routes Are Automatically Generated
A next-demo project that strings together
page.tsx,layout.tsx,route.ts, and'use client'—explaining App Router's "convention over configuration" in one go.
Opening: Huh, Where Did My Routes Go?
When writing an SPA with React Router, routes are explicitly declared—just like what's recorded in our project's readme:
// In an SPA, routes must be declared one by one
<Routes>
<Route path="/todos" element={<Todos />} />
</Routes>
One URL corresponds to one <Route>, crystal clear. But when I opened this Next.js project and searched everywhere for a route table, I found none at all. No routes.ts, no createBrowserRouter, no place to "configure routes."
Yet the pages are real: /, /about, /dashboard, /dashboard/settings, /todos, /api/todos… all accessible.
Where are the routes hiding? The answer lies in the structure of the app/ directory.
This article explains exactly this: In App Router, routes are not "written" by you; they are "placed" by you.
Core: Convention Over Configuration—Files as Routes, Files as Roles
First, a definition:
App Router uses the file system's directory structure to directly generate routes: a folder's hierarchy is the URL's hierarchy, and a file's name is its "role."
This is what Next.js repeatedly emphasizes in its readme—"convention over everything." You don't need to write route configuration; you just need to place files in the agreed-upon locations and give them the agreed-upon names.
Looking at the project directory, it's clear at a glance:
app/ URL
├─ layout.tsx ← Global layout
├─ page.tsx ← Homepage /
├─ about/
│ └─ page.tsx ← About page /about
├─ dashboard/
│ ├─ layout.tsx ← Dashboard layout
│ ├─ page.tsx ← Dashboard home /dashboard
│ └─ settings/
│ └─ page.tsx ← Settings page /dashboard/settings
├─ todos/
│ └─ page.tsx ← Todos page /todos
└─ api/
└─ todos/
└─ route.ts ← Data endpoint /api/todos
Two key rules:
- Files as routes: The location of
app/about/page.tsx(insideabout/) is the URL path (/about). Want to add a/xxxpage? Createapp/xxx/page.tsx, no need to touch any config files. - Files as roles: Within the same directory, different filenames do different things—
page.tsxis a page,layout.tsxis a layout,route.tsis an API endpoint. The filename is the "code" for the component's identity.
Let's break down these "codes" one by one.
page.tsx: One File Is One Page
This is the most basic and common convention. Look at app/about/page.tsx:
function About() {
return <h1>About Us</h1>;
}
export default About;
With just this little code, Next.js automatically registers the /about route for it. page.tsx must default export (export default) a React component; the framework takes it and renders it as a page.
Note: Only
page.tsxbecomes an "accessible page." If you create ahelper.tsxortype.ts(liketodos/type.ts) underapp/, it won't become a route—because the filename isn'tpage, it's just a regular module that gets imported.
So the act of "creating a page" is simplified from two steps ("write route config + write component") to one step ("create folder + write page.tsx").
layout.tsx: Nested Layouts, Shared Shell for Parent-Child Pages
layout.tsx solves the problem of "multiple pages sharing the same structure." The project has two layers of nested layouts.
First layer, the root layout app/layout.tsx, which wraps all pages:
export const metadata: Metadata = {
title: "Create Next App", // 🔑 SEO title
description: "Generated by create next app",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
<nav> {/* Global nav, present on every page */}
<ul>
<li><Link href="/">Home</Link></li>
<li><Link href="/about">About Us</Link></li>
<li><Link href="/dashboard">Dashboard</Link></li>
<li><Link href="/todos">Todos</Link></li>
</ul>
</nav>
{children} {/* 🔑 Child page renders here */}
</body>
</html>
);
}
Note two things:
{children}is the injection point for child page content. When visiting/about, theAboutcomponent replaces{children}and renders, while the outer<nav>navigation persists.export const metadatais the convention Next.js provides for giving each page SEO meta information. Here,title/descriptionwill be directly rendered as<title>,<meta name="description">, part of how SSR is SEO-friendly.
Second layer, the sub-layout app/dashboard/layout.tsx:
export default function DashboardLayout({ children }: LayoutProps<"/dashboard">) {
return (
<div>
<nav><Link href="/dashboard/settings">settings</Link></nav>
{children}
</div>
);
}
Layouts nest: When visiting /dashboard/settings, the actual render structure is—
RootLayout (global nav)
└─ DashboardLayout (settings nav)
└─ SettingsPage (settings page content)
This is the value of layout.tsx: A layout.tsx in a directory only affects that directory and its subdirectories. The settings nav inside dashboard won't appear on the homepage.
route.ts: "Backend Endpoints" Inside the Same Project
This is the most SPA-mindset-shattering part of App Router—you can actually write API endpoints directly inside a frontend project. Look at app/api/todos/route.ts:
// In Next.js, everything except 'use client' is backend
// /api data endpoints still satisfy App Router conventions
// route.ts returns json, it's a data endpoint
let todos: Todo[] = [
{ id: 1, content: 'Learn AppRouter', completed: true },
{ id: 2, content: 'Develop Next.js personal site', completed: false },
];
// 🔑 Exported function name = HTTP method: GET handles GET requests
export async function GET() {
return Response.json(todos);
}
export async function POST(req: Request) {
const data = await req.json();
const newTodo: Todo = { id: Date.now(), content: data.content, completed: false };
todos.push(newTodo);
return Response.json(newTodo);
}
The rules are equally "convention-based":
- The file is placed under
app/api/, namedroute.ts; it's no longer a page, but an endpoint. - The exported function name is the HTTP method—
GET,POST,PUT,DELETE… one file handles all operations for a resource. - The frontend can directly
fetch('/api/todos')to hit it, no cross-origin issues, no need to spin up a separate Node service.
This is why Next.js is called a "full-stack framework": app/page.tsx is the frontend, app/api/*/route.ts is the backend, same project, same conventions, same port.
Note: The
todoshere is an in-memory array; data is wiped on server restart. A real project would connect to a database. The demo uses it just to clarify the structure.
'use client': The Final Piece of the File-as-Role Puzzle
In convention-based routing, there's another "role marker" hidden at the top of a component, which is this line in the project's app/todos/page.tsx:
'use client';
import { useState, useEffect } from 'react';
export default function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([]);
// ...
}
To understand it, you first need to know a default rule of App Router:
Components under
app/are Server Components by default (rendered on the server). Only by writing'use client'does it become a Client Component.
Comparing two files in the project, the difference is clear:
app/page.tsx (Homepage) |
app/todos/page.tsx (Todos page) |
|
|---|---|---|
Has 'use client'? |
❌ No | ✅ Yes |
| Type | Server Component | Client Component |
Can use useState/useEffect/onClick |
❌ Cannot | ✅ Can |
| Where rendered | Server only | Server + Browser |
Why must todos/page.tsx have it? Because it uses useState, useEffect, onClick—these interactive capabilities only exist in the browser. A purely presentational page.tsx (the homepage is just an <h1>) doesn't need it; the cost of adding it is bundling more JS to the browser.
A Counter-Intuitive Truth: 'use client' Components Also Render Once on the Server
Many people (including my past self) think 'use client' means "this component only renders in the browser." Wrong. The truth is:
'use client'doesn't "kick" the component out of the server; it "marks" it as needing hydration. It still renders to HTML on the server first, then sends an extra JS bundle to the browser to activate interactivity.
Let's verify this ourselves. The initial state value in todos/page.tsx is an empty array:
const [todos, setTodos] = useState<Todo[]>([]); // 🔑 Initial is empty array
useEffect(() => { // ⚠️ useEffect only runs after client-side hydration
fetchTodos();
}, []);
So what does the user see the first instant they open /todos? An empty list.
Because the server-side render uses the initial value useState([]), the <ul> in the SSR'd HTML is empty. After the browser downloads the JS and completes hydration, useEffect runs for the first time to fetch('/api/todos') data, setTodos updates, and the list content appears.
This is what it means for a 'use client' component to "run twice":
'use client' component = rendered twice
Server-side Browser
┌──────────────┐ ┌─────────────────┐
│ React renders │ ── HTML ──► │ Gets static HTML │
│ JSX ──► HTML │ │ first, visible │
└──────────────┘ └────────┬────────┘
│ Download JS chunk
▼
┌─────────────────┐
│ Hydration │
│ Bind events, │
│ activate interact│
│ useEffect runs │
└─────────────────┘
Remember in one sentence: SSR is responsible for "seeing," hydration is responsible for "working."
⚠️ Pitfall:
useEffectnever executes on the server side. So if you want "users/crawlers to see data directly on first screen," you can't rely onuseEffectto fetch data; you must use a Server Component to query the data on the server and render it into HTML—this is the pattern Next.js promotes.
Three Real-World Pitfalls, Picked Up Along the Way
Since the code was genuinely written, the pitfalls in it are worth mentioning—all are mistakes beginners are highly likely to make.
Pitfall 1: Added a task, but the list doesn't refresh ⚠️
const handleAdd = async () => {
if (!text.trim()) return;
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text }),
});
// ⚠️ POST succeeded, but the local todos state wasn't updated!
// Backend pushed it in, but the frontend doesn't see the new task
};
You click "Add," the request goes out, the backend data is in, but no new task appears on the page—because the local todos state hasn't changed at all. The frontend state and the backend in-memory array are two independent data flows and won't auto-sync. The correct approach is to setTodos optimistically after a successful POST, or re-run fetchTodos().
Pitfall 2: The delete button is a dummy
{todos.map((item) => (
<li key={item.id}>
<span>{item.content}</span>
<button>Delete</button> // ⚠️ No onClick bound, clicking does nothing
</li>
))}
<button>Delete</button> has no event handler; clicking it does nothing. It should have a DELETE request + setTodos filtering.
Pitfall 3: console.log debug residue
const data: Todo[] = await res.json();
setTodos(data);
console.log(data); // ⚠️ Should be deleted before going live; logs every time after hydration
Wrap-up: One Table to Remember the Entire App Router Convention
Collect all the "codes" into one table; this is your quick reference for building Next.js pages from now on:
| File/Syntax | Role | What it becomes |
|---|---|---|
app/xxx/page.tsx |
Page | URL /xxx |
app/xxx/layout.tsx |
Layout | Shared shell wrapping that directory and its subdirectories |
app/api/xxx/route.ts |
Endpoint | URL /api/xxx, exports GET/POST etc. |
'use client' at top of component |
Marker | Makes the component an interactive Client Component |
Other .tsx/.ts (like type.ts) |
Regular module | Only imported, does not generate a route |
Back to the opening question—"Where did the routes go?"
In App Router, routes are not written; they are "placed": put files in the right location, give them the right names, and routes, layouts, and endpoints are all automatically in place.
Next time you want to add a /profile page, don't look for a route table; just create app/profile/page.tsx, no route configuration needed.
An open question: Convention-based routing saves the trouble of writing route config, but it also means once the directory structure is set, it's hard to "trick" the framework. If you want a URL to look different from the directory (like /post/:id), how is it solved in App Router? Feel free to discuss dynamic routing in the comments.