Next.js App Router and File-Based Routing Explained
Web DevelopmentLesson 1 of 45 min read
What you'll learn
- Map a folder tree under app/ to the URLs it produces
- Write a root layout, a nested layout and a dynamic route that reads params
- Use loading, error and not-found files to handle each state of a route
- Navigate between pages with Link and avoid the common routing errors
On this page
A neighbourhood tool library wants a website: a home page, a list of tools, and a page for each tool. In a plain React single-page app you would install a router, write a route table and keep it in sync with your files. Next.js removes that step. You create folders and special files inside an app/ directory, and the folder names become the URLs. The structure of your project is the route table.
Folders are segments, page.tsx makes them public
Each folder inside app/ is a URL segment, but a folder only becomes a reachable page when it contains a file named page.tsx (or .js, .jsx, .ts). That means you can keep helper files such as components next to your routes without accidentally publishing them. Here is the tool library laid out on disk, with the URL each file creates.
app/
layout.tsx -> wraps every page
page.tsx -> /
not-found.tsx -> any unmatched URL
(info)/
about/
page.tsx -> /about
tools/
layout.tsx -> wraps /tools and below
loading.tsx -> shown while /tools loads
error.tsx -> shown if /tools throws
page.tsx -> /tools
[toolId]/
page.tsx -> /tools/drill, /tools/ladder
api/
health/
route.ts -> GET /api/health (JSON)Three details are worth noticing. Parentheses make a route group: (info) organises files without appearing in the URL, so /about not /info/about. Square brackets make a dynamic segment: [toolId] matches any single value and hands it to your code as a parameter. And a route.ts file creates an HTTP endpoint instead of a page, so a folder cannot contain both a page and a route at the same level.
The root layout and the first page
Every App Router project needs a root layout at app/layout.tsx. It must render the html and body tags, because Next.js does not add them for you. A layout receives the current page as children, and it stays mounted when you navigate between pages beneath it, so its state and DOM are preserved.
import type { Metadata } from "next";
import Link from "next/link";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: { default: "Toolshed", template: "%s | Toolshed" },
description: "Borrow tools from neighbours instead of buying them.",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<nav>
<Link href="/">Home</Link> <Link href="/tools">Tools</Link> <Link href="/about">About</Link>
</nav>
{children}
</body>
</html>
);
}import Link from "next/link";
export default function HomePage() {
return (
<main>
<h1>Borrow, do not buy</h1>
<p>Forty-two neighbours share drills, ladders and a tile cutter.</p>
<Link href="/tools">Browse the shelf</Link>
</main>
);
}Visiting / renders the navigation bar from the layout with three links on one line, followed by the heading, the sentence and a link. Clicking "Tools" does not reload the whole document: Link fetches what is needed for the new page and swaps it in, and the nav stays exactly where it is. The metadata export sets the tab title to "Toolshed" and, thanks to the template, child pages that export a title of "Drill" will show "Drill | Toolshed".
Dynamic routes, params and not-found
A page inside a dynamic folder receives the segment values through a params prop. In Next.js 15 and later, params is a Promise that you await; in earlier versions it was a plain object. Check the version in your package.json, because the type you write differs, and the examples here use the newer form. Missing records should call notFound(), which renders the nearest not-found.tsx and sends a 404 status.
// lib/tools.ts
export type Tool = { id: string; name: string; deposit: number };
const TOOLS: Tool[] = [
{ id: "drill", name: "Cordless drill", deposit: 20 },
{ id: "ladder", name: "Three-step ladder", deposit: 10 },
{ id: "tile-cutter", name: "Tile cutter", deposit: 35 },
];
export function listTools(): Tool[] {
return TOOLS;
}
export function findTool(id: string): Tool | undefined {
return TOOLS.find((tool) => tool.id === id);
}
// app/tools/[toolId]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { findTool } from "@/lib/tools";
type Props = { params: Promise<{ toolId: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { toolId } = await params;
const tool = findTool(toolId);
return { title: tool ? tool.name : "Tool not found" };
}
export default async function ToolPage({ params }: Props) {
const { toolId } = await params;
const tool = findTool(toolId);
if (!tool) notFound();
return (
<article>
<h1>{tool.name}</h1>
<p>Refundable deposit: {tool.deposit} pounds.</p>
</article>
);
}Going to /tools/ladder shows a heading "Three-step ladder" with the deposit line beneath it and the tab title "Three-step ladder | Toolshed". Going to /tools/chainsaw finds no record, so notFound() stops rendering and the not-found page appears with a 404 status. The @/ import alias is configured by the project template in tsconfig.json; if you chose not to enable it, use relative imports.
Files for every state of a route
| File | Purpose | Notes |
|---|---|---|
page.tsx | The UI for a URL | Makes the folder publicly routable |
layout.tsx | Shared UI around child routes | Preserved across navigation; root layout is required |
loading.tsx | Fallback shown while the segment's data loads | Wraps the page in a Suspense boundary for you |
error.tsx | Fallback when rendering throws | Must be a Client Component ("use client") |
not-found.tsx | UI for notFound() and unmatched URLs | A root-level one handles URLs that match nothing |
route.ts | An HTTP handler (GET, POST...) | Cannot share a segment with page.tsx |
"use client";
export default function ToolsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<section>
<h2>The shelf could not be loaded</h2>
<p>Please try again. Reference: {error.digest ?? "none"}</p>
<button type="button" onClick={() => reset()}>Try again</button>
</section>
);
}Common mistakes
- Creating a folder without `page.tsx`. The URL returns 404 because nothing is public until a page file exists.
- A root layout without `html` and `body`. Next.js reports an error that the root layout is missing the required tags, and the page does not render.
- Using `<a href>` for internal links. It works but triggers a full page load and loses the layout state; use
Linkfromnext/link. - Forgetting `"use client"` in `error.tsx`. The build fails, since error boundaries need client-side behaviour and event handlers.
- Accessing `params.toolId` directly in newer versions. Next.js 15 logs a warning that
paramsshould be awaited before its properties are used; in later releases it is an error. - Wrong folder casing.
app/Tools/page.tsxcreates/Tools, not/tools, and URL matching is case-sensitive on most hosts.
How this shows up in real projects and interviews
In a code review you will mostly be asked to place things correctly: shared chrome in a layout, a per-page title in metadata, empty states in not-found, and loading UI in loading.tsx. Interviewers for React roles that use Next.js often ask how routing differs from React Router and what a layout is. A good answer says routes come from the file system, layouts persist across navigations, and special files provide loading, error and not-found states per segment. Older projects may still use the pages/ directory; both routers can coexist during migration, but new work generally targets app/.
Try it yourself
Create a new Next.js app and build a small recipe site: a home page, /recipes listing three recipes, and /recipes/[slug] that shows a recipe or a custom not-found page. Add a recipes layout with a sidebar, a loading.tsx that you can see by adding an await new Promise(r => setTimeout(r, 1500)) in the list page, and a route group called (legal) containing /privacy.
Show a hint
Run npm run dev and check that (legal) does not appear in the URL. To test not-found, visit a slug that is not in your data and confirm the 404 status in the Network tab.
Key takeaways
- Folders under
app/become URL segments, and apage.tsxfile is what makes a segment reachable. - The root layout is required and must render
htmlandbody; nested layouts persist across navigations. - Dynamic segments use
[name], route groups use(name), and in Next.js 15 and laterparamsis awaited. loading.tsx,error.tsx(client component) andnot-found.tsxgive each route its own loading, failure and missing states.
Test yourself
A trick from this lesson's ride: A folder is a street; only a page.tsx opens it to visitors. Parentheses are invisible. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
How does routing work in the Next.js App Router?
Each folder inside app/ is a URL segment, and a page.tsx file inside it makes that segment a public route. Square brackets create dynamic segments, parentheses create route groups that are not part of the URL, and layout.tsx wraps the pages below it.
What is the difference between layout.tsx and page.tsx in Next.js?
A page.tsx is the unique UI of one URL. A layout.tsx wraps all pages in its folder and below, receives them as children, and stays mounted when you navigate between them. Use layouts for shared navigation and chrome, pages for the content that changes.
Why do I get a 404 in Next.js for a folder I created?
A folder alone is not a route. It needs a page.tsx (or route.ts) file with a default export, and the folder name must match the URL exactly, including case. Also check that you are not inside a private folder starting with an underscore, which is excluded from routing.