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. Last checked against Next.js 16.4 (October 2026), which is what create-next-app@latest installs at the time of writing. Next.js 16 needs Node.js 20.9 or later.

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 Router
cd my-fullstack-app
npm run dev

Pick the App Router (the default) 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'] })

export const metadata = {
  title: 'My Full Stack App',
  description: 'Built with the Next.js App Router',
}

export default function RootLayout({
  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 function getPosts() {
  const posts = await db.post.findMany({
    orderBy: { createdAt: 'desc' },
    include: { author: true },
  })
  return posts
}

export default async function BlogPage() {
  const posts = await getPosts()

  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 function BlogLoading() {
  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. It must be a Client Component because React error boundaries run in the browser. Recovery comes in as a prop, retry() (stable since 16.3), which re-fetches and re-renders the segment.

src/app/blog/error.tsx
'use client' // Error boundaries must be Client Components

import { useEffect } from 'react'

export default function BlogError({
  error,
  retry,
}: {
  error: unknown
  retry: () => void
}) {
  useEffect(() => {
    console.error(error) // send this to your error reporting service
  }, [error])

  return (
    <div role="alert">
      <h2>Could not load posts.</h2>
      <button type="button" onClick={() => retry()}>Try again</button>
    </div>
  )
}

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.

Next.js 16 adds an opt-in model on top: Cache Components. Set cacheComponents: true in next.config.ts, then mark the functions or components you want cached with the 'use cache' directive. Leave it off while you learn the basics. Turning it on changes how uncached data outside <Suspense> is treated and can surface build errors.

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 { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/database'
import { getCurrentUser } from '@/lib/dal'

async function createPost(formData: FormData) {
  'use server'

  // Server actions can be called with a direct POST, so check auth here
  const user = await getCurrentUser()
  if (!user) {
    throw new Error('Unauthorized')
  }

  const title = formData.get('title')
  const content = formData.get('content')

  // Validate input: FormData values can be strings, files or null
  if (typeof title !== 'string' || typeof content !== 'string' ||
      !title.trim() || !content.trim()) {
    throw new Error('Title and content are required')
  }

  await db.post.create({
    data: {
      title,
      content,
      slug: title.toLowerCase().replace(/\s+/g, '-'),
      authorId: user.id,
    },
  })

  // Revalidate the blog listing and redirect
  revalidatePath('/blog')
  redirect('/blog')
}

export default function NewPostPage() {
  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>
  )
}

Note the auth check at the top of the action. A server action is reachable with a direct POST request, not only through your form, and a page-level login check does not cover the actions defined on that page. The Next.js data security guide says to verify authentication and authorization inside every action. getCurrentUser comes from the data access layer shown in the next section.

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 — it removes most of the plumbing.

Proxy (formerly Middleware) and Authentication

Next.js 16 renamed middleware.ts to proxy.ts, and the exported function from middleware to proxy. The old name still works but is deprecated. npx @next/codemod@canary middleware-to-proxy . does the rename. Proxy runs before a route renders: redirects, rewrites, header changes, quick cookie checks.

File goes at the root of src/ (next to app/). 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. Double-check it.

src/proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const token = request.cookies.get('session-token')?.value

  // Optimistic check: no session cookie means a redirect to login.
  // Real authorization still happens in the data access layer.
  if (!token) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('redirect', request.nextUrl.pathname)
    return NextResponse.redirect(loginUrl)
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/dashboard/:path*', '/api/admin/:path*'],
}

In Next.js 16, proxy runs on the Node.js runtime and the runtime cannot be changed. If you need the Edge runtime you have to stay on the deprecated middleware.ts for now. Either way, keep it light: it runs on every matched request, including prefetches. Database queries belong in the data access layer.

Auth.js handles OAuth, sessions, and token refresh. Clerk works if you want hosted auth and accept the vendor lock-in. Proxy gives fast redirects. The library handles identity. Neither replaces a check next to the data.

Do not pass identity from proxy to your pages through custom request headers like x-user-id. Any route the matcher skips will accept a header the client sent itself. Server actions on excluded paths skip proxy as well. And CVE-2025-29927 showed that middleware-only authorization can be bypassed entirely (fixed in 14.2.25 and 15.2.3). Treat proxy as an optimistic first check. Verify the session again wherever data is read or written, in a small server-only data access layer:

src/lib/dal.ts
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { verifySession } from '@/lib/auth'

// One session check per request, reused by pages, route handlers and actions
export const getCurrentUser = cache(async () => {
  const token = (await cookies()).get('session-token')?.value
  if (!token) return null

  // Your auth library's check: signature + expiry, or a database session lookup
  const session = await verifySession(token)
  if (!session) return null

  return { id: session.userId, role: session.role }
})

cache from React deduplicates the lookup within a single request, so calling getCurrentUser() from a layout, a page and an action costs one session check. import 'server-only' turns an accidental import from a Client Component into a build error.

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. On image-heavy pages that is often the easiest LCP improvement available. Measure before and after with Lighthouse.

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 (a static shell served immediately, with dynamic parts streaming in) stayed experimental through v15. In Next.js 16 the experimental ppr flag is gone and you get this behavior through Cache Components (cacheComponents: true).

Bundle analysis: Next.js 16 no longer prints per-route Size and First Load JS columns in next build. With Turbopack (the default bundler in 16), run npx next analyze (16.1+) for an interactive view of client and server modules with import chains. On Webpack builds, use @next/bundle-analyzer. A large client bundle usually means a 'use client' too high in the component tree.

On Caching

Next.js caching is still being reworked. The defaults changed between 14 and 15, and 16 added the opt-in Cache Components model. Build without caching first, add it when you measure a problem.

AS

Anurag Sinha

Founder & Lead Developer

Anurag is a full stack developer and self-taught programmer from India with over five years of experience building web applications, APIs, and cloud-native systems. He researches, writes and reviews every tutorial on Codertronix, and updates them when the tools change.