Next.js Routing: 27 Page Files, 412 URLs, Three Conventions Unused
A census of one production app/ tree: which of the nine reserved filenames actually earn a file, and the 40 links, 13 redirects and 2 routers that move between them.
Next.js routing is a folder tree, not a config file. A folder under app/ becomes a URL segment, a page.tsx inside it makes that segment routable, and a handful of other reserved filenames attach behaviour to the same segment. This storefront's 27 page files produce 412 prerendered URLs, because two of those folders are dynamic segments.
That is the whole model, and the reason "how do I add a route in Next.js" has a one-sentence answer. The part worth writing about is the second half — which of the reserved filenames you actually end up needing, and how you move between routes once they exist. Below is a census of both, taken from this site's src/app tree and its latest production build.
The reserved filenames, and how many of each this app has
Every file in the App Router's routing contract is a default export in a file with a specific name. Nothing is registered anywhere; the filename is the registration.
| File | What it does | In this repo |
|---|---|---|
page.tsx | Makes the segment a routable URL | 27 |
layout.tsx | Wraps the segment and everything under it; persists across navigations | 2 |
route.ts | Answers the segment with a Response instead of HTML | 4 |
not-found.tsx | UI for notFound() and unmatched URLs | 1 |
error.tsx | Client-side boundary for a throw in the segment's render | 1 |
loading.tsx | Instant fallback while the segment streams | 0 |
template.tsx | Like a layout, but remounts on every navigation | 0 |
default.tsx | Fallback slot content for parallel routes | 0 |
global-error.tsx | Boundary for throws in the root layout itself | 1 (added with this post) |
Three of the nine have zero files, and a fourth had zero until the audit behind this post added it. That is the more interesting half of the table: a tutorial shows you all nine; a real app of this size reaches for six. The section at the end says why each of the other three never earned a file.
The two layout.tsx files are src/app/layout.tsx (the root: <html>, the Outfit font, the theme script, the analytics beacon) and src/app/dashboard/layout.tsx, which supplies the authenticated shell, sets robots: { index: false, follow: false } for every route beneath it, and — the load-bearing line — calls redirect("/login?next=/dashboard") when getClaims() comes back empty. A layout is the natural home for a guard because it runs for every route beneath it, which is the pattern React protected routes covers in full.
Folders become URLs: the static half
There are 25 top-level folders under src/app. Every one of them is a URL segment that a person or a crawler is meant to see — pricing/, license/, templates/, plus the seven pSEO category hubs like nextjs-admin-template/. Nothing in this tree is wrapped in parentheses, which is why this app uses zero route groups: a route group hides a folder from the URL, and there is no folder here that wants hiding.
src/app/
page.tsx → /
pricing/page.tsx → /pricing
templates/page.tsx → /templates
templates/[slug]/page.tsx → /templates/asoc-lura-admin (×111)
blog/page.tsx → /blog
blog/[slug]/page.tsx → /blog/nextjs-routing (×136)
blog/feed.xml/route.ts → /blog/feed.xml
api/download/route.ts → /api/download
dashboard/layout.tsx → wraps /dashboard and /dashboard/settings
Note blog/feed.xml/ — a folder whose name contains a dot. Segments are literal strings, so a filename-looking URL is just a folder with route.ts in it. That is also how sitemap.ts and robots.ts work at the root, except those two are special-cased metadata files rather than route handlers.
Two dynamic segments do most of the work
A folder named [slug] matches any single segment and hands you its value. This app has exactly two of them, templates/[slug] and blog/[slug], and between them they account for 247 of the site's URLs — the 111 catalog products and the 136 blog posts. A third pattern, blog/[slug]/opengraph-image, generates each post's social card.
// src/app/templates/[slug]/page.tsx
type Params = { slug: string };
export function generateStaticParams(): Params[] {
return catalog.map((p) => ({ slug: p.slug }));
}
export default async function TemplateDetailPage({
params,
}: {
params: Promise<Params>;
}) {
const { slug } = await params;
const product = getProduct(slug);
if (!product) notFound();
// …three JSON-LD blocks, then:
return <TemplateDetailTemplate product={product} />;
}
Two things in that snippet are Next.js 16 specifics worth copying. params is a Promise and has to be awaited — a sync destructure that worked in Next 14 is now a type error. And generateStaticParams is what turns one file into 111 prerendered pages at build time; without it the same file still serves all 111 URLs, just on demand. The full accounting of what that choice costs is in Next.js dynamic routes, including the dynamicParams asymmetry between these two segments.
Catch-all segments ([...slug]) and optional catch-alls ([[...slug]]) match multiple segments at once. This app has zero of both, for the same reason it has no route groups: every URL shape here is known at build time and enumerable, so a catch-all would only be a way to accept URLs nobody asked for.
Route handlers: the four files that answer with data
A route.ts replaces page.tsx in a segment and exports HTTP-method functions instead of a component. The four here are the ones that must exist as endpoints rather than pages:
| Route | Method | Why it isn't a page |
|---|---|---|
/api/download | GET | Authorises, then 302s to a signed Supabase URL |
/api/webhooks/lemonsqueezy | POST | Receives a signed webhook; nothing renders |
/auth/callback | GET | Exchanges an OAuth code for a session cookie, then redirects |
/blog/feed.xml | GET | Returns RSS XML with its own Content-Type |
A segment cannot have both page.tsx and route.ts — they are two answers to the same URL, and the build treats the collision as an error. The rule that decides between them is the response body: HTML for a human, anything else for a machine. The RSS one is worked through end to end in the App Router RSS feed post.
Two of these four pin the Node runtime because they call node:crypto; the other two would run anywhere. That two-line detail is the whole subject of Vercel vs Cloudflare Pages.
Navigating: 40 links, 13 redirects, 2 routers
Once the routes exist, moving between them is three APIs, and the choice between them is not stylistic:
<Link>— 40 tags across 23 files. Every internal navigation a user initiates. It renders a real<a href>, so it works with JavaScript off, and it prefetches the target on hover or viewport entry. There are zero raw<a href="/...">internal links in this codebase, which is enforced at the deploy gate: a literal internal href on a raw anchor trips@next/next/no-html-link-for-pagesand loses client-side navigation.redirect()— 13 calls across 8 files. Every one of them depends on state the server just read: whether a session exists, whether an OAuth exchange succeeded, whether a download is authorised. None of them could be anext.config.tsredirect, because config redirects are evaluated before any of that is known. The full inventory is in Next.js redirects.useRouter()— 2 call sites.DashboardTabs.tsxandAuthCard.tsx, both Client Components that navigate in response to something the user did rather than something the render decided. Two out of 92 components is the honest ratio: imperative navigation is the exception, not the default.
// src/components/organisms/Header.tsx — the ordinary case
<Link href="/templates" className="text-base font-medium ...">
Templates
</Link>
The mistake this rule prevents is the React-app habit of routing through an onClick handler. A button that calls router.push("/pricing") produces no href, so a crawler cannot follow it, middle-click cannot open it, and a screen reader announces a button where a link belongs. Use <Link> unless the navigation is a consequence of something else finishing.
The four conventions this app went without
loading.tsx — zero. A loading file is a Suspense boundary the framework wires for you, and it buys streaming. A page rendered at build time has nothing left to stream: 412 of this site's 420 routes are already HTML on disk before a request arrives. React Suspense: 376 prerendered pages, zero boundaries works through why that stays true even for the eight dynamic ones.
template.tsx — zero. A template is a layout that remounts on every navigation, which you want when per-navigation state must reset — an entry animation, a form that should not survive a route change. Nothing here has that requirement, and the remount is a real cost.
default.tsx — zero. It only exists to serve parallel routes (@slot folders), which this app has none of. Its sibling feature, intercepting routes, is also unused here for a concrete reason: the live-preview modal shows a cross-origin iframe, not one of our own routes.
global-error.tsx — one, added with this post. The audit that produced this census found the gap: error.tsx does not wrap the layout above it in the same segment, so anything thrown inside the root layout had no boundary at all. That file, and the error.digest the boundary was discarding, are the subject of Next.js error boundaries.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| A folder exists but the URL 404s | The folder has no page.tsx (or route.ts) | A folder alone only creates a segment; a route needs a default-exporting file inside it |
params.slug is undefined, or TypeScript errors on it | params is a Promise in Next.js 15+ | const { slug } = await params; — and type the prop as Promise<{ slug: string }> |
| A dynamic route serves fine in dev but is missing from the build output | No generateStaticParams, so nothing is prerendered | Export it to enumerate the params; without it the route is rendered on demand, which is a choice, not a bug |
| Build fails with a route collision | Two files resolve to the same URL — commonly page.tsx and route.ts in one folder | Pick one: HTML for a human, Response for a machine |
| Clicking a link does a full page reload | It is a raw <a href="/...">, not a <Link> | Convert it; @next/next/no-html-link-for-pages catches this in lint |
redirect() seems to throw an error you can't catch | It signals by throwing, by design | Never wrap it in a try/catch that swallows — call it outside, or rethrow anything matching Next's redirect error |
A new page has no <h1> in the audit but looks fine | Layout supplied the visual heading; the page didn't | Route files own their own heading hierarchy — layouts persist, headings don't cascade |
FAQ
How do I create a route in Next.js?
Make a folder under app/ whose name is the URL segment you want, and put a page.tsx in it that default-exports a component. Nothing else registers it. app/pricing/page.tsx serves /pricing the moment the file exists.
What is the difference between page.tsx and route.ts?
page.tsx returns a React component and answers with HTML; route.ts exports GET/POST/etc. and answers with a Response object. One segment can have one or the other, never both. This app has 27 of the first and 4 of the second.
Do I need a router library like React Router? No — and adding one fights the framework. Next.js resolves routes from the filesystem before any JavaScript runs, which is what lets 412 of this site's URLs exist as HTML on disk. A client-side router would move that work back into the browser. See React vs Remix for where the two models actually diverge.
How do I get the current URL inside a component?
usePathname() in a Client Component; in a Server Component, read it from the props the route already gives you — params for dynamic segments, searchParams for the query string. Reaching for a client hook to learn something the server already knew is the most common way a page loses its static rendering.
Templates in this post
ASoc Aegis Landing is a security-platform landing template — a multi-page marketing site where every URL is a known, enumerable segment, exactly the shape that makes file-based routing feel like no work at all. ASoc Ally Landing pairs a marketing front with support and resource sections, the case where a second layout.tsx starts to earn its place. ASoc Amplify Landing is a growth-marketing template with a blog built in, so it ships the one dynamic segment most landing sites eventually need.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the routing decisions this post only counts, read dynamic routes, route groups, and redirects.
