Back to Blog
Next.jsReactApp RouterFrontend

Next.js App Router: Complete Guide

Master the Next.js App Router. From routing to data fetching to server components to caching.

B
Bootspring Team
Engineering
December 5, 2022
6 min read

The App Router is Next.js's modern routing system built on React Server Components. Here's how to use it effectively.

File-Based Routing#

app/ ├── page.tsx # / (home) ├── layout.tsx # Root layout ├── loading.tsx # Loading UI ├── error.tsx # Error boundary ├── not-found.tsx # 404 page ├── about/ │ └── page.tsx # /about ├── blog/ │ ├── page.tsx # /blog │ └── [slug]/ │ └── page.tsx # /blog/:slug ├── (marketing)/ # Route group (no URL impact) │ ├── layout.tsx │ ├── pricing/ │ │ └── page.tsx # /pricing │ └── features/ │ └── page.tsx # /features └── api/ └── users/ └── route.ts # /api/users

Pages and Layouts#

1// app/layout.tsx - Root layout (required) 2export default function RootLayout({ 3 children, 4}: { 5 children: React.ReactNode; 6}) { 7 return ( 8 <html lang="en"> 9 <body> 10 <Header /> 11 <main>{children}</main> 12 <Footer /> 13 </body> 14 </html> 15 ); 16} 17 18// app/page.tsx - Home page 19export default function HomePage() { 20 return <h1>Welcome</h1>; 21} 22 23// app/dashboard/layout.tsx - Nested layout 24export default function DashboardLayout({ 25 children, 26}: { 27 children: React.ReactNode; 28}) { 29 return ( 30 <div className="dashboard"> 31 <Sidebar /> 32 <div className="content">{children}</div> 33 </div> 34 ); 35} 36 37// app/dashboard/page.tsx 38export default function DashboardPage() { 39 return <h1>Dashboard</h1>; 40}

Server Components (Default)#

1// Server Components can: 2// - Fetch data directly 3// - Access backend resources 4// - Keep sensitive data on server 5// - Reduce client bundle size 6 7// app/posts/page.tsx 8async function getPosts() { 9 const res = await fetch('https://api.example.com/posts', { 10 cache: 'force-cache', // Default: cache indefinitely 11 }); 12 return res.json(); 13} 14 15export default async function PostsPage() { 16 const posts = await getPosts(); 17 18 return ( 19 <ul> 20 {posts.map((post) => ( 21 <li key={post.id}>{post.title}</li> 22 ))} 23 </ul> 24 ); 25} 26 27// Direct database access 28import { prisma } from '@/lib/prisma'; 29 30export default async function UsersPage() { 31 const users = await prisma.user.findMany(); 32 33 return ( 34 <ul> 35 {users.map((user) => ( 36 <li key={user.id}>{user.name}</li> 37 ))} 38 </ul> 39 ); 40}

Client Components#

1// Add 'use client' directive for interactivity 2'use client'; 3 4import { useState } from 'react'; 5 6export default function Counter() { 7 const [count, setCount] = useState(0); 8 9 return ( 10 <button onClick={() => setCount(count + 1)}> 11 Count: {count} 12 </button> 13 ); 14} 15 16// Mixing Server and Client Components 17// app/page.tsx (Server Component) 18import Counter from './Counter'; // Client Component 19 20async function getData() { 21 const res = await fetch('...'); 22 return res.json(); 23} 24 25export default async function Page() { 26 const data = await getData(); 27 28 return ( 29 <div> 30 <h1>{data.title}</h1> 31 <Counter /> {/* Client Component */} 32 </div> 33 ); 34}

Data Fetching#

1// Fetch with caching options 2async function getData() { 3 // Default: cache forever 4 const res1 = await fetch('https://api.example.com/data'); 5 6 // Revalidate every 60 seconds 7 const res2 = await fetch('https://api.example.com/data', { 8 next: { revalidate: 60 }, 9 }); 10 11 // No caching 12 const res3 = await fetch('https://api.example.com/data', { 13 cache: 'no-store', 14 }); 15 16 return { res1, res2, res3 }; 17} 18 19// Parallel data fetching 20async function Page() { 21 // Start both requests simultaneously 22 const [users, posts] = await Promise.all([ 23 getUsers(), 24 getPosts(), 25 ]); 26 27 return ( 28 <div> 29 <UserList users={users} /> 30 <PostList posts={posts} /> 31 </div> 32 ); 33} 34 35// Sequential when dependent 36async function Page({ params }: { params: { id: string } }) { 37 const user = await getUser(params.id); 38 const posts = await getUserPosts(user.id); // Depends on user 39 40 return <UserProfile user={user} posts={posts} />; 41}

Loading and Error States#

1// app/dashboard/loading.tsx 2export default function Loading() { 3 return <div>Loading...</div>; 4} 5 6// app/dashboard/error.tsx 7'use client'; 8 9export default function Error({ 10 error, 11 reset, 12}: { 13 error: Error; 14 reset: () => void; 15}) { 16 return ( 17 <div> 18 <h2>Something went wrong!</h2> 19 <p>{error.message}</p> 20 <button onClick={reset}>Try again</button> 21 </div> 22 ); 23} 24 25// app/not-found.tsx 26export default function NotFound() { 27 return ( 28 <div> 29 <h2>Not Found</h2> 30 <p>Could not find the requested resource.</p> 31 </div> 32 ); 33} 34 35// Trigger not-found programmatically 36import { notFound } from 'next/navigation'; 37 38async function getPost(id: string) { 39 const post = await prisma.post.findUnique({ where: { id } }); 40 41 if (!post) { 42 notFound(); 43 } 44 45 return post; 46}

Dynamic Routes#

1// app/blog/[slug]/page.tsx 2interface Props { 3 params: { slug: string }; 4 searchParams: { [key: string]: string | undefined }; 5} 6 7export default async function BlogPost({ params, searchParams }: Props) { 8 const post = await getPost(params.slug); 9 10 return <article>{post.content}</article>; 11} 12 13// Generate static paths 14export async function generateStaticParams() { 15 const posts = await getPosts(); 16 17 return posts.map((post) => ({ 18 slug: post.slug, 19 })); 20} 21 22// Catch-all routes: [...slug] 23// app/docs/[...slug]/page.tsx 24// Matches /docs/a, /docs/a/b, /docs/a/b/c 25 26// Optional catch-all: [[...slug]] 27// Matches /docs, /docs/a, /docs/a/b

Server Actions#

1// app/actions.ts 2'use server'; 3 4import { revalidatePath } from 'next/cache'; 5import { redirect } from 'next/navigation'; 6 7export async function createPost(formData: FormData) { 8 const title = formData.get('title') as string; 9 const content = formData.get('content') as string; 10 11 await prisma.post.create({ 12 data: { title, content }, 13 }); 14 15 revalidatePath('/posts'); 16 redirect('/posts'); 17} 18 19// app/posts/new/page.tsx 20import { createPost } from '../actions'; 21 22export default function NewPost() { 23 return ( 24 <form action={createPost}> 25 <input name="title" required /> 26 <textarea name="content" required /> 27 <button type="submit">Create</button> 28 </form> 29 ); 30} 31 32// With useFormState for feedback 33'use client'; 34 35import { useFormState } from 'react-dom'; 36import { createPost } from '../actions'; 37 38export default function NewPost() { 39 const [state, formAction] = useFormState(createPost, { error: null }); 40 41 return ( 42 <form action={formAction}> 43 {state.error && <p className="error">{state.error}</p>} 44 <input name="title" required /> 45 <button type="submit">Create</button> 46 </form> 47 ); 48}

Route Handlers (API Routes)#

1// app/api/users/route.ts 2import { NextRequest, NextResponse } from 'next/server'; 3 4export async function GET(request: NextRequest) { 5 const searchParams = request.nextUrl.searchParams; 6 const limit = searchParams.get('limit') || '10'; 7 8 const users = await prisma.user.findMany({ 9 take: parseInt(limit), 10 }); 11 12 return NextResponse.json(users); 13} 14 15export async function POST(request: NextRequest) { 16 const body = await request.json(); 17 18 const user = await prisma.user.create({ 19 data: body, 20 }); 21 22 return NextResponse.json(user, { status: 201 }); 23} 24 25// app/api/users/[id]/route.ts 26export async function GET( 27 request: NextRequest, 28 { params }: { params: { id: string } } 29) { 30 const user = await prisma.user.findUnique({ 31 where: { id: params.id }, 32 }); 33 34 if (!user) { 35 return NextResponse.json({ error: 'Not found' }, { status: 404 }); 36 } 37 38 return NextResponse.json(user); 39}

Metadata#

1// Static metadata 2export const metadata = { 3 title: 'My App', 4 description: 'Welcome to my app', 5}; 6 7// Dynamic metadata 8export async function generateMetadata({ 9 params, 10}: { 11 params: { slug: string }; 12}) { 13 const post = await getPost(params.slug); 14 15 return { 16 title: post.title, 17 description: post.excerpt, 18 openGraph: { 19 title: post.title, 20 images: [post.image], 21 }, 22 }; 23}

Best Practices#

Components: ✓ Default to Server Components ✓ Use Client Components only for interactivity ✓ Keep client bundles small ✓ Colocate data fetching with components Data: ✓ Fetch in parallel when possible ✓ Use appropriate caching strategies ✓ Revalidate on mutations ✓ Handle loading and error states Structure: ✓ Use route groups for organization ✓ Share layouts effectively ✓ Use loading.tsx for streaming ✓ Implement proper error boundaries

Conclusion#

The App Router brings powerful patterns with Server Components, streaming, and server actions. Default to server components, use client components for interactivity, and leverage the built-in caching and revalidation system. Structure your routes with layouts and route groups for maintainable applications.

Share this article

Help spread the word about Bootspring