Why the App Router Requires a Different Mental Model
Next.js 15 App Router is not an incremental upgrade from the Pages Router — it is a fundamentally different architecture based on React Server Components. In the Pages Router, every page ran client-side by default, with getServerSideProps or getStaticProps as explicit escape hatches to the server. In the App Router, every component runs on the server by default. The browser receives pre-rendered HTML plus only the JavaScript needed for interactive components.
This inversion changes how you think about data fetching, state, routing, and component composition. The patterns in this article are the core of working effectively with the App Router rather than fighting it.
Route Groups: Layouts Without URL Segments
Route groups are folders wrapped in parentheses. They are stripped from the URL — app/(marketing)/about/page.tsx is accessible at /about, not /(marketing)/about. Their primary purpose is applying different layout.tsx files to different sections of your app without creating URL segments.
app/
├── (marketing)/
│ ├── layout.tsx # Marketing layout: no sidebar, large hero
│ ├── page.tsx # → /
│ ├── about/page.tsx # → /about
│ └── pricing/page.tsx # → /pricing
├── (app)/
│ ├── layout.tsx # Dashboard layout: sidebar, topbar, user context
│ ├── dashboard/page.tsx # → /dashboard
│ └── projects/page.tsx # → /projects
└── layout.tsx # Root layout: <html><body>, only wraps everything
The (marketing) and (app) route groups can have entirely different layouts, providers, and authentication behaviors. You can also use route groups to co-locate related components with their route without making them publicly accessible routes.
Server Components: The Default Mode
Server Components are the default in the App Router. They run during server rendering, can be async, and can call databases, read files, or fetch from internal APIs directly — no useEffect, no loading state management, no client-side fetch.
// app/dashboard/page.tsx — Server Component (no "use client")
import { createClient } from '@/lib/supabase/server'
import { ProjectCard } from '@/components/ProjectCard'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const { data: projects } = await supabase
.from('projects')
.select('*')
.order('created_at', { ascending: false })
return (
<main>
<h1>Welcome, {user?.email}</h1>
<div className="grid grid-cols-3 gap-4">
{projects?.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</div>
</main>
)
}
Server Components can read server-only environment variables (those without NEXT_PUBLIC_), import server-side libraries (Node.js crypto, filesystem access), and call internal services that should never be exposed to the internet.
Client Components: Add "use client" Only When Needed
Client Components are React components that hydrate in the browser. Add "use client" at the top of a file to make it and all its imported children Client Components. You need Client Components when you use:
- React hooks:
useState,useEffect,useRef,useContext, custom hooks - Browser APIs:
window,document,localStorage,navigator - Event handlers:
onClick,onChange,onSubmitwith stateful logic - Third-party libraries that rely on browser APIs (charts, drag-and-drop, etc.)
'use client'
import { useState } from 'react'
export function SearchBar({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState('')
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && onSearch(query)}
placeholder="Search projects..."
/>
)
}
The Client-Server Boundary: Composition Pattern
One of the most important App Router concepts is the client-server boundary and how to compose across it. You can pass Server Component output as children to Client Components, but you cannot import a Server Component from inside a Client Component.
// CORRECT: Server Component wraps Client Component, passes data as props
// app/projects/page.tsx (Server Component)
import { ProjectList } from '@/components/ProjectList' // Client Component
import { getProjects } from '@/lib/data'
export default async function ProjectsPage() {
const projects = await getProjects() // DB call on server
return <ProjectList initialProjects={projects} /> // Pass data down as props
}
// components/ProjectList.tsx (Client Component)
'use client'
import { useState } from 'react'
export function ProjectList({ initialProjects }) {
const [projects, setProjects] = useState(initialProjects)
// Can now filter, sort, etc. on the client
return ( /* ... */ )
}
The key insight: the server fetches data and passes it to the client as serializable props (plain objects, arrays, strings, numbers). The client takes over from there for interactivity. Never try to pass class instances, functions, or promises as props across the boundary.
loading.tsx and Suspense Boundaries
Every route segment can have a loading.tsx file. Next.js automatically wraps the page in a React Suspense boundary and shows loading.tsx while the page's async Server Component is rendering. This gives instant navigation — the layout renders immediately while the content streams in.
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 bg-gray-100 rounded-xl animate-pulse" />
))}
</div>
)
}
For more granular control, use React Suspense directly inside Server Components to show loading states for specific data-fetching children while the rest of the page renders synchronously.
error.tsx: Per-Route Error Boundaries
Next.js automatically creates an error boundary around each route segment when you define error.tsx. When a Server Component or Client Component in that segment throws, the error boundary catches it and renders your error.tsx instead of crashing the whole page. error.tsx must be a Client Component because it uses React's error boundary API.
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}
Server Actions: Forms Without API Routes
Server Actions let you define async functions that run on the server and can be called directly from Client Components or HTML forms — without writing a Route Handler (app/api/ endpoint). They are marked with the "use server" directive.
// app/projects/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
export async function createProject(formData: FormData) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Unauthorized')
const name = formData.get('name') as string
await supabase.from('projects').insert({
name,
user_id: user.id,
})
revalidatePath('/dashboard') // Invalidate the dashboard cache
}
// Usage in a Client Component:
// <form action={createProject}>
// <input name="name" />
// <button type="submit">Create</button>
// </form>
generateMetadata for Dynamic SEO
The App Router's Metadata API replaces the Pages Router's <Head> pattern. For dynamic metadata based on route params (e.g., a project detail page), export an async generateMetadata function:
// app/projects/[id]/page.tsx
import type { Metadata } from 'next'
import { createClient } from '@/lib/supabase/server'
export async function generateMetadata({ params }): Promise<Metadata> {
const supabase = await createClient()
const { data: project } = await supabase
.from('projects')
.select('name, description')
.eq('id', params.id)
.single()
return {
title: project?.name ?? 'Project',
description: project?.description,
openGraph: {
title: project?.name,
description: project?.description,
},
}
}
Next.js deduplicates the data fetch — if your page component makes the same Supabase query, the result is cached and reused within the same render cycle.