Next.js App Router Patterns: Layouts, Loading States, and Parallel Routes
The App Router introduced powerful primitives that most tutorials skip.
Here are the patterns that actually matter in production.
Layout Nesting
app/
layout.tsx # root layout (html, body)
page.tsx # /
dashboard/
layout.tsx # shared sidebar + nav
page.tsx # /dashboard
settings/
layout.tsx # settings-specific tabs
page.tsx # /dashboard/settings
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1 p-8">{children}</main>
</div>
)
}
Layouts persist across navigations — the sidebar doesn't re-render when you navigate between dashboard pages.
Loading UI
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
This automatically wraps page.tsx in a Suspense boundary. No manual <Suspense> needed.
Parallel Routes
Render multiple pages simultaneously in the same layout:
app/dashboard/
layout.tsx
@analytics/
page.tsx
@revenue/
page.tsx
@users/
page.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
analytics,
revenue,
users,
children,
}: {
analytics: React.ReactNode
revenue: React.ReactNode
users: React.ReactNode
children: React.ReactNode
}) {
return (
<div className="grid grid-cols-3 gap-4">
{analytics}
{revenue}
{users}
</div>
)
}
Each slot loads independently — analytics doesn't block revenue from rendering.
Intercepting Routes
Show a modal when navigating to a URL, but the full page on direct visit:
app/
feed/
page.tsx
@modal/
(.)photo/[id]/
page.tsx # intercepts /photo/[id] from feed
photo/
[id]/
page.tsx # full page on direct visit
Instagram-style photo modals. Same URL, different UI depending on how you got there.
Server vs Client Components
// Server Component (default) — runs on server, no JS shipped
async function ProductList() {
const products = await db.product.findMany() // direct DB access!
return (
<ul>
{products.map(p => (
<li key={p.id}>
<ProductCard product={p} /> {/* can be client component */}
</li>
))}
</ul>
)
}
// Client Component — add interactivity
'use client'
function ProductCard({ product }: { product: Product }) {
const [saved, setSaved] = useState(false)
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => setSaved(!saved)}>
{saved ? 'Saved' : 'Save'}
</button>
</div>
)
}
Server Actions
// Server action — runs on the server, called from client
async function createPost(formData: FormData) {
'use server'
const title = formData.get('title') as string
await db.post.create({ data: { title, authorId: await getUserId() } })
revalidatePath('/posts')
}
// Use in a form — no API route needed
export default function NewPostForm() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" />
<button type="submit">Create Post</button>
</form>
)
}
Route Groups
Organize routes without affecting the URL:
app/
(marketing)/
layout.tsx # marketing layout
page.tsx # /
pricing/
page.tsx # /pricing
(app)/
layout.tsx # authenticated app layout
dashboard/
page.tsx # /dashboard
settings/
page.tsx # /settings
Marketing pages and app pages share a domain but different layouts — no URL impact.
The AI SaaS Starter Kit uses all these patterns: route groups for marketing vs app, parallel routes for the dashboard, and Server Actions for forms. $99 one-time — production-ready from day one.
Top comments (0)