Forget Everything About the Pages Router

The App Router broke half the tutorials on the internet. If you learned Next.js with getServerSideProps and getStaticProps, forget all of it. Different mental model.

Server components by default. Client components are opt-in. Data fetching goes inside the component itself. Forms submit to server functions instead of API routes. Once this clicks, you build faster with less boilerplate. But the first few days will feel wrong if you are coming from the Pages Router, because your instincts will be wrong.

Prerequisites: React basics and at least one React app that talks to a backend.

Project Setup and File Structure

Scaffolding takes 30 seconds.

Terminal
npx create-next-app@latest my-fullstack-app
# Choose: TypeScript, ESLint, Tailwind CSS, src/ directory, App Routercd my-fullstack-app
npm run dev

Pick the App Router (default in v15) and TypeScript. Skipping TypeScript saves you five minutes now and costs you hours later in runtime type bugs. Not worth it.

Project Structure
my-fullstack-app/
+-- src/
� +-- app/
� � +-- layout.tsx // Root layout (wraps entire app)� � +-- page.tsx // Home page (/)� � +-- globals.css // Global styles� � +-- loading.tsx // Loading UI� � +-- error.tsx // Error boundary� � +-- not-found.tsx // 404 page� � +-- dashboard/
� � � +-- layout.tsx // Dashboard layout� � � +-- page.tsx // /dashboard� � � +-- settings/
� � � +-- page.tsx // /dashboard/settings� � +-- blog/
� � +-- page.tsx // /blog� � +-- [slug]/
� � +-- page.tsx // /blog/:slug� +-- components/
� +-- lib/
+-- public/
+-- next.config.ts
+-- package.json

Folders become URL segments. Special files -- page.tsx, layout.tsx, loading.tsx, error.tsx -- control rendering behavior. No react-router-dom. No getStaticPaths.

Non-special files are invisible to the router. Components, tests, utilities -- put them right next to the pages that use them. Colocation over a sprawling components/ folder. This matters more than it sounds.

Routing and Layouts

Nested layouts persist across navigations. Dashboard sidebar does not unmount when users click between settings and analytics. State stays. Scroll position stays. No code needed.

Root layout must include <html> and <body> tags:

src/app/layout.tsx
import { Inter } from'next/font/google'import'./globals.css'import { Navbar } from'@/components/Navbar'import { Footer } from'@/components/Footer'const inter = Inter({ subsets: ['latin'] })
exportconst metadata = {
 title: 'My Full Stack App',
 description: 'Built with Next.js 15 App Router',
}
export default functionRootLayout({
 children,
}: {
 children: React.ReactNode
}) {
 return (
 <html lang="en">
 <body className={inter.className}>
 <Navbar />
 <main>{children}</main>
 <Footer />
 </body>
 </html>
 )
}

Dashboard with its own sidebar? Nested layout inside dashboard/. Root layout stays untouched.

Dynamic Routes

Square brackets: [slug]. Captures that URL segment as a parameter. TypeScript infers the type. Nothing to configure.

Route Groups

Parentheses around a folder name -- (marketing) or (auth) -- invisible to the URL. Marketing pages get one layout, app pages get another. Plan these early. Retrofitting them later means restructuring your entire app/ directory, and that is as painful as it sounds.

Parallel Routes

Sounds over-engineered until a dashboard comes along where analytics, activity feed, and notifications need to load independently. Slow notifications API? Analytics panel still renders instantly. Define slots with @analytics, @activity, @notifications and consume them as props in the parent layout. Each slot gets its own loading and error boundary.

Server Components vs Client Components

This is the only thing that matters if you are migrating.

Every component is a Server Component by default. Runs on the server. Access your database directly. Zero JavaScript shipped to the browser.

Need useState, useEffect, or an onClick? Add 'use client' at the top. That component ships JS to the browser, gets hydrated, the whole deal. But here is where people wreck their bundle size: they put 'use client' on a page component because it needs one interactive button. Now the entire page and everything it imports is client-side JavaScript. The fix is obvious once you see it -- pull the button into its own tiny Client Component. Page stays on the server. Everything around the button stays on the server.

Big Server Components, small Client Components. Fetch data at the top. Interactivity only at the leaves.

Product listing page. The page queries the database -- Server Component. Product cards render titles, prices, images -- Server Components. The only Client Component is the "Add to Cart" button, because it needs onClick and local cart state. Everything else stays off the client bundle entirely.

Data Fetching

Your page components are Server Components. So you just fetch data. Async/await in the component body. No special exports. No API routes.

src/app/blog/page.tsx
import { db } from'@/lib/database'import { PostCard } from'@/components/PostCard'async functiongetPosts() {
 const posts = await db.post.findMany({
 orderBy: { createdAt: 'desc' },
 include: { author: true },
 })
 return posts
}
export default async functionBlogPage() {
 const posts = awaitgetPosts()
 return (
 <section>
 <h1>Blog</h1>
 <div className="grid grid-cols-3 gap-6">
 {posts.map((post) => (
 <PostCard key={post.id} post={post} />
 ))}
 </div>
 </section>
 )
}

Create loading.tsx next to page.tsx. Next.js wraps the page in a Suspense boundary automatically. Loading UI shows while data fetches, real content streams in.

Loading States

src/app/blog/loading.tsx
export default functionBlogLoading() {
 return (
 <div className="grid grid-cols-3 gap-6">
 {Array.from({ length: 6 }).map((_, i) => (
 <div key={i} className="animate-pulse">
 <div className="bg-gray-200 h-48 rounded-lg" />
 <div className="mt-4 h-4 bg-gray-200 rounded w-3/4" />
 <div className="mt-2 h-4 bg-gray-200 rounded w-1/2" />
 </div>
 ))}
 </div>
 )
}

Error Boundaries

Create error.tsx. Catches errors for that route segment. Rest of the app keeps working. Must be a Client Component because it needs useState for reset.

Caching (This Will Confuse You)

In v14, fetch was aggressively cached by default. Users saw stale data. Developers had no idea. Bad default.

v15 changed it. No caching by default now. Every request is fresh unless you explicitly opt in with { cache: 'force-cache' } or { next: { revalidate: 3600 } }. Much better. But if you are reading tutorials written for v14, the caching advice is wrong. Or rather, it was correct for a set of defaults that no longer exist.

Server Actions and Forms

This is what makes the App Router a full-stack framework instead of a React renderer with routing.

Think about every React form you have ever built. Prevent default, serialize FormData, POST to an endpoint, handle response, update state, show errors, manage loading state. That is a lot of plumbing before you get to actual business logic. A server action collapses all of it into one function. Mark it with 'use server'. It runs entirely on the server. The form submits standard FormData, so it works even with JavaScript disabled. revalidatePath busts the cache for any page that shows the affected data, and the redirect happens server-side.

src/app/blog/new/page.tsx
import { db } from'@/lib/database'import { redirect } from'next/navigation'import { revalidatePath } from'next/cache'async functioncreatePost(formData: FormData) {
 'use server'const title = formData.get('title') as string
 const content = formData.get('content') as string
 // Validate inputif (!title || !content) {
 throw newError('Title and content are required')
 }
 // Write directly to the databaseawait db.post.create({
 data: { title, content, slug: title.toLowerCase().replace(/\s+/g, '-') },
 })
 // Revalidate the blog listing and redirectrevalidatePath('/blog')
 redirect('/blog')
}
export default functionNewPostPage() {
 return (
 <form action={createPost}>
 <input name="title" placeholder="Post title" required />
 <textarea name="content" placeholder="Write your post..." required />
 <button type="submit">Publish</button>
 </form>
 )
}

Need a loading spinner or inline validation? Combine server actions with useFormStatus and useActionState in a Client Component. The action still runs on the server. The hooks just give you client-side feedback.

Server actions are not universal. Real-time collaboration still needs WebSockets. Streaming updates need a different pattern. But for standard CRUD -- creating records, updating profiles, processing forms -- this is the cleanest approach in any React framework right now. And it is not close.

Middleware and Authentication

Runs before the request hits your application. Token checks, redirects, header injection -- at the edge, before any component executes.

File goes at the root of src/. The matcher config controls which routes it applies to. Get the matcher wrong and you protect your login page, which means nobody can log in. This has happened to me. Double-check it.

src/middleware.ts
import { NextResponse } from'next/server'import type { NextRequest } from'next/server'import { verifyToken } from'@/lib/auth'export async functionmiddleware(request: NextRequest) {
 const token = request.cookies.get('session-token')?.value
 // Check if the user is authenticatedif (!token) {
 const loginUrl = newURL('/login', request.url)
 loginUrl.searchParams.set('redirect', request.nextUrl.pathname)
 return NextResponse.redirect(loginUrl)
 }
 // Verify the token is validconst user = awaitverifyToken(token)
 if (!user) {
 return NextResponse.redirect(newURL('/login', request.url))
 }
 // Add user info to request headers for downstream useconst headers = newHeaders(request.headers)
 headers.set('x-user-id', user.id)
 headers.set('x-user-role', user.role)
 return NextResponse.next({ request: { headers } })
}
export const config = {
 matcher: ['/dashboard/:path*', '/api/admin/:path*'],
}

Middleware runs at the edge. No fs. No heavy Node packages. Token verification, header checks, redirects. That is it. Database queries belong in server components.

Auth.js handles OAuth, sessions, and token refresh. Clerk works if you want hosted auth and accept the vendor lock-in. Middleware handles route protection. Library handles identity.

Worth stealing: attach user info to request headers in middleware (the x-user-id and x-user-role above), read them in server components. No extra database lookups. No prop drilling. No React context. Data flows through the request itself.

Deployment and Optimization

Vercel is obvious since they build Next.js. Docker on AWS, DigitalOcean, Railway all work too. Docker gives you the most control.

next/image instead of plain <img> tags. Automatic WebP/AVIF, per-screen-size resizing, lazy loading. LCP can drop from 3+ seconds to under 1 second on image-heavy pages.

next/font downloads Google Fonts at build time, serves them locally. No external request. No layout shift. If your CLS score is bad, fonts are probably part of the problem.

Partial prerendering is new in v15. Static shell from CDN, dynamic content streams in. Users see something immediately.

Bundle analysis: npm run build, check the output. Any route over 100KB of client JS deserves investigation. Usually a 'use client' too high in the component tree. @next/bundle-analyzer gives you a visual breakdown.

On Caching

Next.js caching is still being reworked. The defaults changed between 14 and 15, and they will probably change again. Build without caching first, add it when you measure a problem.

Ravi Krishnan

Ravi Krishnan

Backend Engineer & Systems Programmer

Ravi is a backend engineer and systems programmer with deep expertise in Rust, Go, and distributed systems. He writes about performance optimization and scalable architecture.