Nested Layouts in Next.js 16 — Missing layout.js Caused 404 on Every Sub-Page
Your layout.js is missing and every sub-page returns 404.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- ✓Solid grasp of React fundamentals (components, props, state)
- ✓Node.js 18+ installed
- ✓Basic understanding of Next.js concepts (Server Components, App Router)
- ✓Familiarity with the command line
- The filesystem IS the router —
app/blog/page.tsx=/blog, no config files needed - Every route segment needs a
page.js— alayout.jsalone is not enough - Layouts persist across navigations and do not re-render; templates re-render on every navigation
- Missing
layout.jsat any level causes 404s on all child routes — Next.js requires a layout chain from root to leaf
Think of Next.js's filesystem router like a filing cabinet. Each drawer (folder) is a URL segment. Each file inside is a page. The layout.js files are like the drawer's label and dividers — they organize the content but don't replace the actual documents. If you remove a divider (layout), the drawer still exists, but the documents (pages) inside become inaccessible. The filing cabinet doesn't use a map or index — the physical arrangement of folders IS the map.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
You created app/blog/page.tsx and expected /blog to work. It returned 404. You checked the file name, the folder name, the casing — everything looks right. But Next.js is telling you the route doesn't exist. Welcome to the filesystem router, where the folder structure IS the routing configuration.
If you're coming from React Router, this is the biggest mental shift. React Router uses a config file where you declare routes: <Route path="/blog" element={<Blog />} />. Next.js uses the filesystem: app/blog/page.tsx = /blog. No config, no registration, no BrowserRouter. The folder hierarchy IS the route hierarchy.
This article covers everything about the App Router: layouts, templates, nested routes, dynamic segments, route groups, parallel routes, and the common pitfalls that cause 404s. By the end, you'll understand why the filesystem router is both more powerful and more restrictive than React Router — and how to use it without fighting it.
The Filesystem IS the Router — Unlearn Everything You Know
If you're coming from React Router, you're used to this: a routes.js file where you declare <Route path="/blog" element={<Blog />} />. Next.js doesn't work that way. The filesystem IS the router. app/blog/page.tsx maps to /blog. app/blog/[slug]/page.tsx maps to /blog/:slug. There is no route config file. There is no BrowserRouter wrapper. The folder structure IS the route structure.
This is a hard mental shift. React Router treats routes as data — you can map, filter, and compose them programmatically. Next.js treats routes as files — you create, delete, and move them with the filesystem. You can't dynamically generate routes at runtime (without generateStaticParams). You can't wrap routes with HOCs in a config file. The filesystem is the source of truth.
The benefit: zero boilerplate. No route config, no BrowserRouter, no Switch component. Create a file, get a route. The cost: less flexibility. You can't programmatically add routes at runtime. You can't use regex patterns in paths. You can't nest routes arbitrarily without creating folders.
page.tsx files that just re-exported components from a central router. They lost all the benefits of code splitting and lazy loading. The fix: delete the central router and let each page.tsx import its own components.app/blog/[slug]/page.tsx → /blog/:slugLayouts — The Persistent UI Wrapper
A layout.js file wraps its child pages and nested layouts. It persists across navigations — when the user navigates from /blog to /blog/hello-world, the layout does NOT re-render. Only the page.tsx content changes. This is the key performance feature of layouts: shared UI (headers, footers, sidebars) renders once and stays mounted.
Layouts can be nested. app/layout.js wraps everything. app/blog/layout.js wraps all blog pages. app/blog/[slug]/layout.js wraps individual blog posts. Each layout adds a wrapper around its children. The nesting is automatic — you don't need to configure it.
But there's a critical rule: layouts don't re-render when the child page changes. If you have a sidebar with navigation state, that state persists across page navigations. This is usually what you want — but it can cause bugs if the layout depends on data that changes between pages. In that case, use a template.js instead (it re-renders on every navigation).
template.js (which re-renders on every navigation) or use a client component with useEffect to refetch on route change.Templates — When Layouts Are Not Enough
Templates (template.js) are like layouts, but they re-render on every navigation. If a layout persists state (e.g., a sidebar with scroll position), a template resets it. Use templates when: you need to run an effect on every page view (analytics, page view tracking), you want to reset state between pages, or you're animating page transitions.
The performance trade-off: templates re-render their children on every navigation, which means React unmounts and remounts the component tree. This is more expensive than a layout, which keeps the tree mounted. Use layouts by default, templates only when you need the reset behavior.
Common use case: page view tracking. If you put analytics in a layout, it fires once when the layout mounts. If the user navigates to another page in the same layout, the analytics event doesn't fire again. Put analytics in a template to fire on every page view.
Route Groups — Organizing Without Affecting URLs
Route groups are folders wrapped in parentheses: (marketing), (dashboard), (auth). They don't affect the URL path. app/(marketing)/about/page.tsx maps to /about, not /(marketing)/about. This is useful for: organizing routes by feature, applying different layouts to different sections, and separating public vs authenticated routes.
A common pattern: app/(public)/layout.tsx for the marketing site layout, app/(dashboard)/layout.tsx for the authenticated app layout. Both layouts can have different headers, footers, and navigation. The routes under each group share the same layout without affecting the URL.
Route groups can be nested: app/(marketing)/blog/[slug]/page.tsx still maps to /blog/:slug. The (marketing) group doesn't appear in the URL. This is purely organizational.
(public) and (admin) route groups with different layouts. The public layout had a marketing header; the admin layout had a sidebar. Same URL structure, different layouts.(group) organize routes without affecting URLs(marketing)/blog and (dashboard)/blog are different routesDynamic Routes — [slug], [...slug], and [[...slug]]
Dynamic routes use bracket syntax in folder names. [slug] matches a single segment: /blog/hello-world → params.slug = "hello-world". [...slug] matches one or more segments: /blog/2024/01/hello → params.slug = ["2024", "01", "hello"]. [[...slug]] matches zero or more segments: /blog → params.slug = undefined, /blog/hello → params.slug = ["hello"].
The params prop is a Promise in Next.js 16 — you must await it. This is a change from Next.js 15 where params was a synchronous object. The async params enables better streaming and partial rendering.
Dynamic routes can be pre-rendered at build time using generateStaticParams. This function returns an array of param objects, and Next.js generates static HTML for each one. For a blog with 1,000 posts, generateStaticParams creates 1,000 static HTML files at build time.
await params in your page components. It's async in Next.js 16.[[...slug]] for their catch-all route expecting it to match /blog (zero segments). But they also had app/blog/page.tsx which created a conflict. Next.js prioritized the explicit page.tsx over the catch-all. The fix: remove the explicit page.tsx and handle the root case inside the catch-all component.[slug] matches one segment, [...slug] matches one or more, [[...slug]] matches zero or moreapp/blog/page.tsx beats app/blog/[slug]/page.tsxparams is a Promise in Next.js 16 — always await itgenerateStaticParams to pre-render dynamic routes at build timeParallel Routes — Multiple Pages in One Layout
Parallel routes let you render multiple pages in the same layout simultaneously. They use @slot folders: app/@feed/page.tsx and app/@sidebar/page.tsx render independently in the same layout. The layout receives both slots as props and can arrange them however it wants.
This is useful for dashboards with independent sections. A dashboard might have a main feed, a sidebar with notifications, and a header with user info — all rendering independently. Each slot has its own loading and error states. If the feed fails to load, the sidebar still works.
Parallel routes also enable conditional rendering based on route. You can show different slots for different authentication states. For example, app/@login/page.tsx for unauthenticated users and app/@dashboard/page.tsx for authenticated users — both at the same URL.
default.js in each slot to handle the case when no page matchesIntercepting Routes — Modals and Drill-Downs
Intercepting routes let you show a route's content in a modal while keeping the underlying page in the background. They use (..) prefix syntax: app/feed/(..)photo/[id]/page.tsx intercepts the /photo/:id route when navigated to from the feed.
The intercept levels: (.) matches the same level, (..) matches one level up, (..)(..) matches two levels up, (...) matches from the root app directory. This is useful for photo galleries, modals, and drill-down navigation where you want to show content in a modal without losing the underlying page.
When the user navigates directly to the URL (not from the feed), the intercept is bypassed and the normal page renders. This gives you the best of both worlds: smooth in-app navigation with modals, and direct URL access with full pages.
(..)photo/[id] — one level up from the feed. This pattern is used by Instagram, Twitter, and most social platforms.(.) for same level, (..) for one level up, (...) for root levelLoading and Error States — The Parallel UI Contract
Every route segment can have loading.js and error.js files. loading.js shows a fallback UI while the page is loading (streaming or data fetching). error.js catches errors in the page and shows a fallback. Both are optional — if you don't provide them, Next.js uses default behavior (blank page for loading, error page for errors).
loading.js wraps the page in a React Suspense boundary. It shows immediately while the page content streams in. This is critical for perceived performance — users see a skeleton or spinner instead of a blank screen. The loading state is shown for the first render and for any Suspense boundaries that are still streaming.
error.js is an error boundary. It catches errors thrown during rendering, data fetching, or in client components. It receives the error and a reset function to retry. Error boundaries are scoped to their segment — an error in app/blog/[slug]/page.tsx shows the error UI for that post, not the entire app.
loading.js, the page was blank for 3 seconds. Users thought the app was broken. Adding a skeleton loading state reduced bounce rate by 40% — even though the actual load time didn't change.Metadata — SEO and Social Cards
Next.js has a built-in metadata API for SEO. Export a metadata object or generateMetadata function from any layout.js or page.js. The metadata is automatically injected into the <head> of the page — title, description, Open Graph, Twitter cards, and more.
Static metadata: export a metadata object with title, description, openGraph, and twitter fields. Dynamic metadata: export a generateMetadata function that receives params and searchParams and returns the metadata object. This is useful for blog posts where the title and description come from the database.
Metadata merges from parent to child. The root layout sets the base title and description. Child pages can override or extend them. If a child page doesn't export metadata, the parent's metadata is used. This is the same pattern as layouts — parent provides defaults, children override.
generateMetadata from their blog post pages. Google indexed every post with the root layout's title. After adding dynamic metadata, organic traffic increased by 300% in 2 weeks.metadata for static SEO data, generateMetadata for dynamic datatitle and description for every page — they're critical for SEOgenerateMetadata for blog posts, product pages, and any dynamic contentThe 404 Bug — Why Missing layout.js Breaks Everything
The most common routing bug in Next.js: you create app/dashboard/settings/page.tsx but /dashboard/settings returns 404. The cause is almost always a missing layout.js in a parent segment. Next.js requires a layout chain from the root to every leaf page. If any segment in the chain is missing a layout, the route returns 404.
This is because Next.js wraps every page in its parent layouts. The rendering chain is: root layout → segment layout → segment layout → page. If any link in that chain is missing, Next.js can't render the page. The error message is misleading — it says "404" but the real issue is a missing layout, not a missing page.
To debug: check every folder in the path from root to the page. Each folder should have either a layout.js or be a leaf with a page.js. If a folder has neither, routes under it will 404. The exception is route groups (group) — they don't need their own layout (they inherit from the parent).
app/(dashboard)/layout.js thinking it was unused because the dashboard still rendered. But all sub-routes (/dashboard/settings, /dashboard/analytics) returned 404. The root layout was wrapping the dashboard page directly, skipping the dashboard layout. The sub-routes couldn't render because they had no layout chain.The Pages Router — Legacy Routes and Migration
Next.js supports two routers: the App Router (app/) and the Pages Router (pages/). The Pages Router is the legacy system from Next.js 12 and earlier. It uses pages/index.js for /, pages/blog/[slug].js for /blog/:slug, and pages/api/ for API routes. It does NOT support layouts, Server Components, or streaming.
You can use both routers in the same project. Routes in app/ take priority over routes in pages/. If you have app/about/page.tsx and pages/about.js, the App Router version wins. This is useful for incremental migration — you can move routes from Pages Router to App Router one at a time.
But there's a catch: the Pages Router doesn't support layouts. If you have a pages/_app.js with a custom layout, it won't work with App Router pages. You need to migrate the layout to app/layout.js and move the pages to app/ as well. You can't mix layouts between the two routers.
pages/_app.js for the old pages and app/layout.js for the new ones. The migration took 3 months with zero downtime. Each page was migrated independently, tested, and deployed.The 404 Bug Deep Dive — Debugging Missing Layouts
When a route returns 404 and you've confirmed the file exists, the most likely cause is a missing layout.js in a parent segment. Next.js builds a layout tree from root to leaf. If any segment in the tree is missing a layout, the entire branch returns 404.
To debug: start at the route that 404s and walk up the folder hierarchy. app/dashboard/settings/page.tsx needs: app/layout.js (root), app/dashboard/layout.js (or app/(dashboard)/layout.js), and app/dashboard/settings/page.tsx. If any of those is missing, the route 404s.
The exception: route groups (group) don't need their own layout. If you have app/(marketing)/about/page.tsx, you only need app/layout.js (root) and app/(marketing)/about/page.tsx. The (marketing) group inherits the root layout unless you add a layout.js inside it.
/dashboard/settings. The file existed. The folder existed. The route was correct. The cause: they had app/(dashboard)/layout.js but deleted app/layout.js (the root layout). The root layout is required — without it, no route works. Restoring app/layout.js fixed all 404s.The Template vs Layout Decision — When to Use Each
The decision between layout.js and template.js comes down to one question: do you want the UI to persist across navigations? If yes, use a layout. If no, use a template.
Layouts are for: navigation bars, sidebars, footers, and any UI that should stay mounted when the user navigates. Templates are for: page view analytics, entrance animations, scroll-to-top behavior, and any UI that should reset on every navigation.
There's a performance cost to templates. Because they re-render on every navigation, React unmounts and remounts the component tree. This means all state is lost — useState, useReducer, and any DOM state (scroll position, input values) are reset. If you need to preserve state, use a layout. If you need to reset state, use a template.
useEffect to refetch only when the user data changed. API calls dropped by 80%.The Link Component — Client-Side Navigation Without Full Page Reloads
Next.js's Link component enables client-side navigation. When you use <Link href="/about">About</Link>, Next.js prefetches the linked page (if it's in the viewport) and navigates without a full page reload. This is the difference between a SPA-like experience and traditional MPA navigation.
Link prefetches statically rendered pages by default. For dynamic pages, it prefetches the data. This means navigation feels instant — the JavaScript and data are already loaded before the user clicks. You can disable prefetching with prefetch={false} for rarely-used links.
But there's a trap: Link only works for client-side navigation. If you use an <a> tag instead of <Link>, you get a full page reload. This loses all client-side state, re-downloads the entire JavaScript bundle, and feels slow. Always use <Link> for internal navigation.
<a> tags for all internal navigation because they were used to React Router's <Link>. Their app felt slow because every navigation caused a full page reload. Lighthouse score was 45 for performance. Switching to <Link> improved the score to 85 — navigation was instant.<Link> for internal navigation — it prefetches and navigates without full reload<a> for external links — they need full page navigation<Link> prefetches statically rendered pages by defaultprefetch={false} for rarely-used links to save bandwidthThe useRouter Hook — Programmatic Navigation
For programmatic navigation (after form submission, after login, etc.), use the useRouter hook from next/navigation. It provides push(), replace(), back(), forward(), and refresh(). push() adds to the history stack, replace() replaces the current entry, refresh() re-renders the current route without a full page reload.
useRouter is a client-side hook — it only works in Client Components ('use client'). For Server Components, use from redirect()next/navigation instead. throws a redirect error that Next.js catches and handles — it works in Server Components, Server Actions, and Route Handlers.redirect()
The method is unique to Next.js. It re-renders the current route without losing client-side state. This is useful after a mutation (e.g., creating a blog post) — you can refresh the page to show the new data without a full navigation.refresh()
redirect() from next/navigation instead of useRouter.router.push() in a Server Action to redirect after form submission. It didn't work because useRouter is a client-side hook. They switched to redirect() from next/navigation which works in Server Actions. The redirect happened before the response was sent — no client-side JavaScript needed.useRouter is client-side only — use redirect() in Server Components and Server Actionsrouter.push() adds to history, router.replace() doesn'trouter.refresh() re-renders the current route without losing client staterouter.prefetch() manually prefetches a route (Link does this automatically)The Root Layout — Why It's Required and What It Must Contain
The root layout.js is the only required file in a Next.js app (besides at least one page.js). It must contain <html> and <body> tags. Only the root layout should have these tags — nested layouts should not repeat them. If you add <html> in a nested layout, you'll get nested HTML tags in the rendered output, which is invalid HTML.
The root layout is where you import global CSS, set metadata defaults, and wrap the app with providers (theme, auth, etc.). It's the outermost shell of your application. Every page renders inside this layout.
If you delete the root layout, every route in the app returns 404. Next.js doesn't have a default layout — you must provide one. The scaffolded create-next-app creates it for you, but if you're starting from scratch or accidentally delete it, you need to recreate it.
<html> and <body> tags in a nested layout to override the lang attribute. The rendered HTML had nested <html> tags — <html><body><html><body>...</body></html></body></html>. Browsers handled it gracefully, but Lighthouse flagged it as invalid HTML. The fix: set the lang attribute in the root layout only.The Link Component — Prefetching and Client-Side Navigation
Next.js's <Link> component is not the same as an <a> tag. It prefetches linked pages in the background (when the link is in the viewport), and navigates without a full page reload. This is the foundation of Next.js's fast navigation — pages feel instant because they're already loaded before the user clicks.
Prefetching behavior: statically rendered pages are fully prefetched (HTML + data). Dynamically rendered pages are prefetched only up to the loading boundary (the data is fetched on navigation). You can disable prefetching with prefetch={false} for links that are rarely used.
<Link> also supports scroll={false} to disable scroll-to-top on navigation. This is useful for paginated lists where you want to maintain scroll position. And replace to replace the current history entry instead of pushing a new one.
<a> tags instead of <Link>. Every navigation caused a full page reload — 1.2 seconds of load time. Switching to <Link> made navigation instant (under 100ms) because pages were prefetched before the user clicked.<Link> prefetches pages in the viewport — navigation feels instant<a> for external links only — internal links should use <Link><Link scroll={false}> disables scroll-to-top on navigation<Link prefetch={false}> disables prefetching for rarely-used linksThe usePathname and useSearchParams Hooks
usePathname() returns the current URL pathname (e.g., /blog/hello-world). useSearchParams() returns the current URL search params (e.g., ?page=2). Both are client-side hooks — they only work in Client Components. They're useful for: active link highlighting, conditional rendering based on route, and reading query parameters.
useSearchParams() returns a URLSearchParams object. You can call .get('page'), .has('sort'), or .toString() on it. It's read-only — to update search params, use useRouter().push() or Link with the new URL.
There's also useParams() which returns the route params (the [slug] values). Unlike params in page components (which is a Promise), useParams() returns a synchronous object. It works in both Server and Client Components.
usePathname() to highlight the active navigation item. But they compared the full pathname, so /blog/hello-world didn't match /blog. The fix: use pathname.startsWith('/blog') to match all blog routes, or use a more sophisticated matching function.usePathname() returns the current path — use it for active link highlightinguseSearchParams() returns query parameters — use it for pagination, filters, sortinguseParams() returns route params — works in both Server and Client ComponentsuseRouter().push() to change the URLThe 404 Page — Custom Not-Found Routes
You can create a custom 404 page by adding app/not-found.tsx. This file exports a component that renders when no route matches. It's scoped — if you put app/blog/not-found.tsx, it only shows for 404s under /blog/*. The root app/not-found.tsx catches all unmatched routes.
But there's a subtlety: not-found.tsx only shows for routes that don't exist. If a route exists but the data isn't found (e.g., a blog post with a non-existent slug), you need to call notFound() from next/navigation in your page component. This triggers the nearest not-found.tsx boundary.
notFound() is a function you call from Server Components. It throws a NOT_FOUND error that Next.js catches and renders the nearest not-found.tsx. This is different from returning a 404 response — it uses the layout hierarchy to show the right error UI.
notFound() in the blog post page and created app/blog/not-found.tsx with a custom design. Users now see a branded "Post not found" page instead of a generic error.app/not-found.tsx for a custom 404 pagenotFound() from Server Components when data is missingnot-found.tsx in a route segment only catches 404s in that segmentThe Migration Path — From Pages Router to App Router
Migrating from Pages Router to App Router is not a one-day task. It's a gradual process where you move routes one at a time. Both routers can coexist — App Router routes take priority for matching paths. You can have pages/about.js and app/about/page.tsx — the App Router version wins.
The migration steps: 1) Create app/layout.js with the root layout (move content from pages/_app.js and pages/_document.js), 2) Move routes one at a time from pages/ to app/, 3) Replace getServerSideProps with async Server Components, 4) Replace getStaticProps with in Server Components, 5) Replace fetch()getStaticPaths with generateStaticParams.
The hardest part is migrating data fetching. getServerSideProps becomes an async Server Component. getStaticProps becomes with fetch()next: { revalidate }. getStaticPaths becomes generateStaticParams. The patterns are similar but the syntax is different.
app/layout.js and moved routes one at a time. Each migration was a separate PR with before/after performance comparisons. The result: 40% smaller bundles, 60% faster navigation, and zero downtime.getServerSideProps → async Server Component, getStaticProps → fetch() with revalidategetStaticPaths → generateStaticParams, _app.js → app/layout.jsMissing layout.js — 47 Pages Returned 404 in Production
/dashboard/* returned 404. The root / and /login worked fine. Error rate spiked from 0.1% to 100% on dashboard routes.app/(dashboard)/layout.js thinking it was unused. Next.js requires a layout chain from root to every leaf page. Without layout.js in the dashboard route group, all child routes (/dashboard/settings, /dashboard/analytics, etc.) returned 404 because Next.js couldn't find a valid layout to render them in.layout.js file. Added a CI lint rule that checks for missing layout files in route segments. Added a pre-push hook that runs next build to catch missing layouts before deployment.- Every route segment needs a layout.js or page.js — layouts don't replace pages
- The root layout.js is required — deleting it breaks every route in the app
- Add a CI step that runs
next buildto catch missing layouts before deployment - Use route groups
(group)to organize routes without affecting the URL structure
page.js file. Check if a parent layout.js is missing. Check for case sensitivity issues.(group) to organize layouts without affecting URLs.generateStaticParams is missing for static export. Check if the dynamic segment name matches between folder and component.template.js instead of layout.js. Templates re-render on every navigation; layouts persist. Rename to layout.js if you want persistent state.ls -la app/**/page.{tsx,jsx,js} 2>/dev/nullfind app -name 'page.*' -type fapp/<segment>/page.tsx with a default export component| File | Command / Code | Purpose |
|---|---|---|
| app | export default function RootLayout({ | The Filesystem IS the Router |
| app | export default function BlogLayout({ | Layouts |
| app | 'use client' | Templates |
| app | export default function DashboardLayout({ | Route Groups |
| app | export default async function BlogPage({ | Dynamic Routes |
| app | export default async function Feed() { | Parallel Routes |
| app | export default async function PhotoModal({ | Intercepting Routes |
| app | export default function BlogLoading() { | Loading and Error States |
| app | export async function generateMetadata({ | Metadata |
| app | 'use client' | The 404 Bug |
| app | export default function Navigation() { | The Link Component |
| app | 'use client' | The useRouter Hook |
| app | export const metadata: Metadata = { | The Root Layout |
| app | export default function Pagination({ page, totalPages }: { | The Link Component |
| app | 'use client' | The usePathname and useSearchParams Hooks |
| app | export default function NotFound() { | The 404 Page |
| app | export default async function BlogPost({ | The Migration Path |
Key takeaways
app/blog/[slug]/page.tsx = /blog/:slug, no config neededpage.jslayout.js alone is not enoughlayout.js in any parent segment causes 404 on all child routes(group) organize routes without affecting URLs@slot render independent UIs in the same layoutInterview Questions on This Topic
Explain the difference between layout.js and template.js in the App Router.
/blog to /blog/post-1, the layout does NOT re-render. Only the page.tsx content changes. This means layout state (scroll position, form inputs, sidebar state) is preserved. Templates re-render on every navigation — React unmounts and remounts the component tree. Use layouts for persistent UI (headers, footers, sidebars). Use templates for animations, analytics page views, or any UI that should reset on navigation.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's Next.js. Mark it forged?
11 min read · try the examples if you haven't