Why App Router Changes Everything
The App Router in Next.js 15 replaces the Pages Router with a fundamentally different architecture built on React Server Components. It introduces nested layouts, co-located data fetching, streaming, and granular loading/error states — all using file-system conventions.
Unlike the Pages Router where every page was a Client Component with getServerSideProps / getStaticProps, the App Router makes Server Components the default. Data fetching happens directly inside components using async/await — no more prop drilling from getServerSideProps.
The App Router uses a directory-based convention in the app/ folder. Each folder represents a route segment, and special files (page.tsx, layout.tsx, loading.tsx, error.tsx) define the behavior at each level.
Key Takeaways
File-System Routing Convention
The App Router uses a strict file naming convention to define route behavior. Understanding these special files is the foundation of every Next.js 15 application.
page.tsx defines the UI for a route — it is the only file that makes a route publicly accessible. layout.tsx wraps the page and all nested routes with shared UI. loading.tsx creates an instant loading state using Suspense. error.tsx creates an error boundary. not-found.tsx handles 404s.
Route groups (parentheses folders) organize routes without affecting the URL. Dynamic segments use [brackets]. Catch-all segments use [...slug]. Optional catch-all uses [[...slug]].
// File structure → URL mapping app/ ├── layout.tsx // Root layout (wraps EVERYTHING) ├── page.tsx // → / ├── blog/ │ ├── layout.tsx // Blog layout (persists across blog pages) │ ├── page.tsx // → /blog │ ├── loading.tsx // Instant loading skeleton for /blog │ ├── error.tsx // Error boundary for /blog │ └── [slug]/ │ ├── page.tsx // → /blog/my-post (dynamic route) │ ├── loading.tsx // Loading state for individual posts │ └── not-found.tsx // 404 for invalid slugs ├── (auth)/ // Route group — no URL impact │ ├── layout.tsx // Auth-specific layout │ ├── login/page.tsx // → /login │ └── register/page.tsx // → /register └── (dashboard)/ ├── layout.tsx // Dashboard layout with sidebar ├── analytics/page.tsx // → /analytics └── settings/page.tsx // → /settings
Nested Layouts: Persistent UI Across Routes
Layouts are the most powerful feature of the App Router. A layout wraps its page and all child routes, and it persists across navigations. This means the layout does not re-render when you navigate between sibling routes — state, scroll position, and interactive elements are preserved.
The root layout (app/layout.tsx) is required and must contain <html> and <body> tags. It wraps every page in your application. Nested layouts add progressive UI layers — a dashboard layout adds a sidebar that persists across all dashboard pages.
Layouts receive a children prop that represents the page or nested layout below them. They can also accept parallel route slots via @-prefixed props.
// app/layout.tsx — Root Layout (required) import { Inter } from 'next/font/google'; import './globals.css'; const inter = Inter({ subsets: ['latin'] }); export const metadata = { title: { default: 'Ink & Horizon', template: '%s | Ink & Horizon' }, description: 'Deep technical guides for modern developers', }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body className={inter.className}> <Header /> <main>{children}</main> <Footer /> </body> </html> ); } // app/(dashboard)/layout.tsx — Dashboard Layout // Persists sidebar across /analytics, /settings, etc. export default function DashboardLayout({ children }: { children: React.ReactNode }) { return ( <div className="dashboard-grid"> <Sidebar /> {/* Never re-renders on navigation! */} <div className="dashboard-content">{children}</div> </div> ); }
Key Takeaways
Loading UI and Streaming
loading.tsx creates an instant loading state using React Suspense under the hood. When a user navigates to a route, the loading UI appears immediately while the page's async data fetching completes.
This is fundamentally different from client-side loading spinners. The loading state is rendered on the server as HTML and streamed to the client before the page data is ready. This means users see meaningful UI (skeletons, placeholders) within milliseconds.
You can have loading.tsx at every level of your route tree. The closest loading.tsx to the navigated route is used. This gives you granular control over which parts of the UI show skeletons.
// app/blog/loading.tsx — Instant skeleton for blog listing export default function BlogLoading() { return ( <div className="blog-grid"> {Array.from({ length: 6 }).map((_, i) => ( <div key={i} className="blog-card-skeleton"> <div className="skeleton-image" /> <div className="skeleton-title" /> <div className="skeleton-excerpt" /> <div className="skeleton-meta" /> </div> ))} </div> ); } // app/blog/[slug]/loading.tsx — Skeleton for individual post export default function PostLoading() { return ( <article className="post-skeleton"> <div className="skeleton-hero" /> <div className="skeleton-heading" /> <div className="skeleton-paragraph" /> <div className="skeleton-paragraph" /> <div className="skeleton-code-block" /> </article> ); }
Error Handling with error.tsx
error.tsx creates an error boundary at any level of your route tree. It catches runtime errors in the page and all its children, displaying a fallback UI with a retry option. The layout above the error boundary remains intact and interactive.
Error boundaries must be Client Components (they use React's error boundary pattern internally). They receive two props: error (the Error object) and reset (a function to retry rendering the failed segment).
For global errors (including layout errors), use global-error.tsx in the app root. This replaces the entire page including the root layout's <html> and <body> tags.
// app/blog/error.tsx — Error boundary for blog section 'use client'; import { useEffect } from 'react'; export default function BlogError({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { useEffect(() => { // Log to error reporting service console.error('Blog Error:', error); }, [error]); return ( <div className="error-state"> <h2>Something went wrong loading the blog</h2> <p>{error.message}</p> {error.digest && ( <p className="error-digest">Error ID: {error.digest}</p> )} <button onClick={reset}>Try Again</button> </div> ); }
Key Takeaways
Parallel Routes and Intercepting Routes
Parallel routes let you render multiple pages in the same layout simultaneously, each with independent loading and error states. They use @-prefixed folders: @analytics, @notifications, etc.
The layout receives each parallel route as a named prop alongside children. This enables complex dashboard UIs where different panels can load, error, and navigate independently.
Intercepting routes let you show a route in a modal on the current page while preserving the full page view when accessed directly. Instagram-style photo modals are the classic example — click a photo and it opens in a modal; refresh the page and you see the full photo page.
// Dashboard with Parallel Routes app/(dashboard)/ ├── layout.tsx // Receives @team and @analytics as props ├── page.tsx // → /dashboard (default) ├── @team/ │ ├── page.tsx // Team panel content │ ├── loading.tsx // Independent loading state │ └── error.tsx // Independent error boundary └── @analytics/ ├── page.tsx // Analytics panel content └── loading.tsx // Independent loading state // app/(dashboard)/layout.tsx export default function DashboardLayout({ children, team, // @team parallel route slot analytics, // @analytics parallel route slot }) { return ( <div className="dashboard"> <div className="main-content">{children}</div> <aside className="sidebar">{team}</aside> <section className="metrics">{analytics}</section> </div> ); } // Intercepting Routes — Modal Pattern app/ ├── blog/ │ ├── page.tsx // Blog listing │ └── [slug]/page.tsx // Full blog post page (direct access) ├── @modal/ │ └── (..)blog/[slug]/ // Intercepts /blog/[slug] — shows in modal │ └── page.tsx // Modal view of blog post
Middleware: Edge-Level Request Processing
Next.js middleware runs on the Edge Runtime before every request reaches your routes. It can redirect, rewrite, modify headers, and conditionally block requests — all at the CDN edge for sub-millisecond latency.
Common use cases include authentication guards (redirect unauthenticated users to /login), A/B testing (rewrite to different page variants), geolocation-based routing, feature flags, and rate limiting.
Middleware runs once per request at the edge, making it the fastest place to enforce security rules. It executes before any page rendering begins.
// middleware.ts (root of app) import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const token = request.cookies.get('auth-token')?.value; // 1. Auth Guard: Protect dashboard routes if (pathname.startsWith('/dashboard') && !token) { const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('callbackUrl', pathname); return NextResponse.redirect(loginUrl); } // 2. Geolocation: Redirect Indian users to localized content const country = request.geo?.country; if (pathname === '/' && country === 'IN') { return NextResponse.rewrite(new URL('/in', request.url)); } // 3. Security Headers const response = NextResponse.next(); response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); return response; } // Only run middleware on specific paths export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], };
Key Takeaways
Data Fetching Patterns
In the App Router, data fetching happens directly inside Server Components using async/await. There is no getServerSideProps, getStaticProps, or getInitialProps. You simply fetch data where you need it.
Next.js automatically deduplicates identical fetch() calls made in different components during the same render. If your layout and page both fetch the current user, only one request is made. This uses React's request-level caching.
For parallel data fetching, use Promise.all to avoid waterfalls. For sequential data fetching (when one query depends on another), use normal await. Next.js optimizes both patterns automatically.
// ✅ Parallel fetching — both requests start simultaneously async function Dashboard() { const [user, posts, analytics] = await Promise.all([ getUser(), getPosts(), getAnalytics(), ]); return ( <div> <UserProfile user={user} /> <PostList posts={posts} /> <AnalyticsChart data={analytics} /> </div> ); } // ✅ Sequential fetching — when one depends on another async function UserPosts({ userId }) { const user = await getUser(userId); // First: get user const posts = await getPostsByAuthor(user.name); // Then: get their posts return <PostList posts={posts} author={user} />; } // ✅ Preloading pattern — start fetch early, consume later import { preload } from 'react-dom'; function BlogLayout({ children }) { // Start fetching in the layout (runs first) preload('/api/categories', { as: 'fetch' }); return <div>{children}</div>; }
Production Deployment Checklist
Before deploying a Next.js 15 app to production, verify these critical items. Each one has caused outages at real companies.
1. Build test: Run next build and fix ALL warnings. Warnings in dev become errors in production.
2. Environment variables: Use NEXT_PUBLIC_ prefix only for client-safe variables. Server-only secrets must NOT start with NEXT_PUBLIC_.
3. Image optimization: Use next/image with proper width/height/sizes. Unoptimized images are the #1 Core Web Vitals killer.
4. Bundle analysis: Run ANALYZE=true next build to find heavy imports. Lazy-load anything over 50KB.
5. Error boundaries: Every route segment should have an error.tsx. The global-error.tsx is your safety net.
6. Metadata: Every page.tsx should export metadata or use generateMetadata for dynamic SEO.
7. Caching strategy: Explicitly set caching behavior for every fetch call. The default in Next.js 15 is NO caching.
Key Takeaways
Key Takeaways
The Next.js 15 App Router is the production standard for React applications in 2026. It brings Server Components, streaming, and granular UI patterns that were impossible with the Pages Router.
The core concepts to master: file-system routing conventions, nested layouts, loading/error boundaries, Server Components for data fetching, parallel routes for complex UIs, and middleware for edge-level request processing.
For interviews, be prepared to explain: how layouts persist state across navigations, why loading.tsx is faster than client-side spinners, the difference between route groups and dynamic segments, and how caching changed from Next.js 14 to 15.