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/settingsSpecial files have specific purposes:
page.tsx— The actual page contentlayout.tsx— Wraps page and child routes, persists across navigationloading.tsx— Shown while page is loadingerror.tsx— Error boundary for that route segmentnot-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.