Next.js

Next.js 15: App Router Patterns That Actually Work

Svet Halachev Dec 20, 2025 5 min read
Next.js 15: App Router Patterns That Actually Work

When the App Router dropped, it changed everything about how we structure Next.js applications. Now with Next.js 15, things have matured considerably. The patterns are clearer, the gotchas are documented, and building with it actually feels good.

Here's what I've learned building production apps with it.

The File-Based Routing System

If you're coming from Pages Router, the new system takes adjustment. Your folder structure IS your route structure:

app/
├── page.tsx                # /
├── about/
│   └── page.tsx            # /about
├── blog/
│   ├── page.tsx            # /blog
│   └── [slug]/
│       └── page.tsx        # /blog/my-post
├── dashboard/
│   ├── layout.tsx          # Shared dashboard layout
│   ├── page.tsx            # /dashboard
│   └── settings/
│       └── page.tsx        # /dashboard/settings

Special files have specific purposes:

  • page.tsx — The actual page content

  • layout.tsx — Wraps page and child routes, persists across navigation

  • loading.tsx — Shown while page is loading

  • error.tsx — Error boundary for that route segment

  • not-found.tsx — Custom 404 for that route

Layouts: Think in Nesting

Layouts are probably the biggest mental shift. They don't remount when you navigate between child routes:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <main className="flex-1 p-8">{children}</main>
    </div>
  )
}

Navigate from /dashboard to /dashboard/settings and the sidebar stays mounted. State is preserved, no flicker. This is huge for complex UIs.

Server Components by Default

Everything in the app directory is a Server Component unless you say otherwise. This means:

// app/blog/page.tsx — This runs on the server
import { db } from '@/lib/db'

export default async function BlogPage() {
  const posts = await db.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
  })

  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  )
}

No useEffect, no loading states for initial data, no client-side fetching. The data is there when the page arrives.

When you need interactivity, mark just that component as a Client Component:

'use client'

import { useState } from 'react'

export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false)

  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '❤️' : '🤍'}
    </button>
  )
}

The key insight: push 'use client' as far down the component tree as possible. Your page can be a Server Component that renders a Client Component for just the interactive parts.

Data Fetching Patterns

Fetching in Server Components

Just use async/await. No hooks, no special syntax:

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 3600 }, // Cache for 1 hour
  })

  if (!res.ok) return null
  return res.json()
}

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)

  if (!post) notFound()

  return <Article post={post} />
}

Parallel Data Fetching

Don't waterfall your requests:

export default async function DashboardPage() {
  // Bad: Sequential (slow)
  const user = await getUser()
  const posts = await getPosts()
  const analytics = await getAnalytics()

  // Good: Parallel (fast)
  const [user, posts, analytics] = await Promise.all([
    getUser(),
    getPosts(),
    getAnalytics(),
  ])

  return <Dashboard user={user} posts={posts} analytics={analytics} />
}

Server Actions

Server Actions replaced API routes for mutations. They're functions that run on the server but can be called from the client:

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  await db.post.create({
    data: { title, content },
  })

  revalidatePath('/blog')
}

Use them in forms:

import { createPost } from './actions'

export function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </form>
  )
}

Works without JavaScript, progressively enhances when JS loads. The form just works.

Caching (The Part Everyone Gets Wrong)

Next.js 15 changed caching defaults—fetch requests and route handlers are no longer cached by default. You opt IN to caching now:

// Not cached (default in Next.js 15)
const data = await fetch('https://api.example.com/data')

// Cached and revalidated every hour
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 },
})

// Cached forever (until manual revalidation)
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache',
})

Use revalidatePath and revalidateTag to invalidate cache when data changes:

'use server'

export async function updatePost(id: string, data: FormData) {
  await db.post.update({ where: { id }, data: { /* ... */ } })

  revalidatePath('/blog')           // Revalidate a path
  revalidateTag('posts')            // Or revalidate by tag
}

Metadata and SEO

Metadata is built into the framework:

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPost(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.featuredImage],
    },
  }
}

Static metadata for simpler pages:

export const metadata: Metadata = {
  title: 'About Us',
  description: 'Learn more about our company',
}

The Takeaway

The App Router is opinionated, but those opinions lead to faster, more maintainable applications. Server Components eliminate whole categories of loading state management. Layouts make complex UIs simpler. Server Actions make forms work the way forms should.

It's a different way of thinking, but once it clicks, you won't want to go back.

Related Articles