跪拜 Guibai
← Back to the summary

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:

  1. Files as routes: The location of app/about/page.tsx (inside about/) is the URL path (/about). Want to add a /xxx page? Create app/xxx/page.tsx, no need to touch any config files.
  2. Files as roles: Within the same directory, different filenames do different things—page.tsx is a page, layout.tsx is a layout, route.ts is 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.tsx becomes an "accessible page." If you create a helper.tsx or type.ts (like todos/type.ts) under app/, it won't become a route—because the filename isn't page, 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:

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":

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 todos here 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: useEffect never executes on the server side. So if you want "users/crawlers to see data directly on first screen," you can't rely on useEffect to 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.