The App Router's mental model is a folder tree that doubles as your routing table: every segment is a directory, and a handful of reserved filenames — page.tsx, layout.tsx, loading.tsx, error.tsx — decide what renders at that segment. It took me longer than I'd like to admit to stop fighting this and start using the conventions as designed, mostly because the Pages Router habit of "one file equals one route" doesn't map cleanly onto a router where a route can be composed of five or six files nested at different depths.
Layouts vs. templates
A layout.tsx wraps its segment and everything below it, and — this is the part people miss — it does not re-render on navigation between sibling routes. State inside a layout, like an open sidebar or a scroll position, survives as the user moves from /dashboard/settings to /dashboard/billing. A template.tsx looks identical on the surface but creates a new instance on every navigation, which is what you want when you need mount-triggered effects or per-navigation animations to actually re-fire. Most routes never need a template; reach for one only when you've confirmed a layout's persistence is the thing breaking your UI.
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard-shell">
<Sidebar />
<main>{children}</main>
</div>
);
}
Route groups for organisation, not for URLs
Wrapping a folder name in parentheses — (marketing), (app) — creates a route group: it lets you organise routes and apply a shared layout to a set of segments without that folder name appearing in the URL. This is how you get a marketing site and an authenticated app living in the same app/ tree with completely different layouts, header, and fonts, while both still resolve to root-relative paths. It's organisational sugar only; it has no runtime behavior beyond scoping layouts and letting you have multiple root layouts in one project.
Parallel and intercepting routes
Parallel routes (folders prefixed with @, like @modal) let you render more than one page into named slots of the same layout simultaneously — a dashboard with an independently-loading analytics panel and activity feed, each with its own loading.tsx and error boundary. Intercepting routes (prefixed with (.), (..), or (..)(..), depending on how many segments up you're intercepting) are the pattern behind the "photo opens in a modal on top of the feed, but a direct link or refresh loads the full photo page" behavior you see on sites like Instagram. Combined, they're how you get a modal that's also a real, shareable, bookmarkable URL — without them, you're stuck choosing between a client-side-only modal with no URL, or a full page navigation that loses the feed underneath it.
An intercepting route conventionally renders into a parallel route slot (often @modal) defined in the nearest shared layout. If you add the intercepting folder but forget to wire the @modal slot into the layout's children, Next.js has nowhere to render the intercepted content and the convention silently does nothing.
Where the server/client boundary actually needs to sit
Everything under app/ is a Server Component by default; 'use client' at the top of a file opts that module and everything it imports into the client bundle. The mistake I see most often is putting 'use client' too high in the tree — on a whole page — because one button needs an onClick. The fix is almost always to push the boundary down: keep the page and its data-fetching as a Server Component, and extract just the interactive fragment (the button, the form, the dropdown) into its own small client component that the server component imports and renders. Server Components can pass serializable props to client components, but not the other way around — a client component can't import and render a server component directly, only receive one as children.
import { getProduct } from '@/lib/products';
import { AddToCartButton } from './add-to-cart-button';
// Server Component: fetches data, no client JS shipped for this part
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Only this small piece needs client-side interactivity */}
<AddToCartButton productId={product.id} />
</article>
);
}
Loading and error states are scoped per segment, not global
A loading.tsx file automatically wraps its segment's page.tsx in a Suspense boundary, and an error.tsx wraps it in an error boundary — both scoped to that route segment, not the whole app. This means a slow-loading sidebar widget can show its own skeleton without blocking the rest of the page, and an error thrown deep in one route doesn't take down a shared layout that renders above it. In practice this is the single biggest win over the Pages Router's all-or-nothing getServerSideProps blocking model: you get granular, co-located loading and error states for free just by adding a file, no custom Suspense wiring required.
Error boundaries rely on React's componentDidCatch-equivalent lifecycle, which only exists on the client. error.tsx requires 'use client' at the top — forgetting it is one of the more common App Router build errors people hit early on.
Wrapping up
The App Router's file conventions look like a lot of new vocabulary at first, but they collapse into one idea: routing, layout persistence, loading states, and error boundaries are all expressed as files instead of code you write yourself. The convention names are less important than internalising the two behavioral rules that trip people up most — layouts persist across sibling navigations while templates don't, and the server/client boundary should sit as low in the tree as the interactive part of the UI actually requires.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.