Next.js 15 App Router Patterns Every SaaS Needs in 2026
TL;DR
The App Router unlocked patterns impossible in Pages Router that directly improve SaaS UX. Server Actions eliminate entire API routes. Parallel Routes enable modal-as-page patterns (critical for billing and settings UIs). Intercepting Routes let you open detail views without leaving a list page. Suspense streaming means your dashboard loads progressively instead of all-at-once. Most boilerplates use App Router but implement only the basics — these patterns separate polished SaaS products from rough ones.
Key Takeaways
- Server Actions: form submissions, mutations without
/apiroutes, progressive enhancement by default - Parallel Routes (
@slot): render multiple independent pages simultaneously — dashboards, modals, split views - Intercepting Routes (
(.)): open a modal with a real URL that deep-links and survives refresh - Streaming + Suspense: show shell UI instantly, stream in data as it resolves
- How boilerplates handle it: ShipFast uses Server Actions; T3 still defaults to tRPC; Supastarter mixes both
1. Server Actions: Eliminate API Route Boilerplate
The biggest App Router DX improvement. Mutations go directly in components or actions.ts files — no fetch('/api/...'), no route handlers for simple mutations.
// app/dashboard/settings/actions.ts
'use server';
import { auth } from '@/auth';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const updateProfileSchema = z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
});
export async function updateProfile(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const parsed = updateProfileSchema.safeParse({
name: formData.get('name'),
bio: formData.get('bio'),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
await db.user.update({
where: { id: session.user.id },
data: parsed.data,
});
revalidatePath('/dashboard/settings');
return { success: true };
}
// app/dashboard/settings/page.tsx — form that calls Server Action:
import { updateProfile } from './actions';
export default function SettingsPage() {
return (
<form action={updateProfile} className="space-y-4">
<div>
<label htmlFor="name">Display Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="bio">Bio</label>
<textarea id="bio" name="bio" rows={3} />
</div>
<button type="submit">Save Changes</button>
</form>
);
}
// Client component with optimistic updates + loading state:
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { updateProfile } from './actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Save Changes'}
</button>
);
}
export function ProfileForm({ user }: { user: { name: string; bio?: string } }) {
const [state, formAction] = useFormState(updateProfile, null);
return (
<form action={formAction}>
{state?.error && (
<div className="text-red-500">
{Object.values(state.error).flat().join(', ')}
</div>
)}
{state?.success && <div className="text-green-500">Saved!</div>}
<input name="name" defaultValue={user.name} />
<textarea name="bio" defaultValue={user.bio} />
<SubmitButton />
</form>
);
}
SaaS uses for Server Actions: updating profile, changing plan display name, toggling feature flags, inviting team members, deleting account — any mutation that doesn't need a separate API endpoint.
2. Parallel Routes: Dashboards and Modal-as-Page
Parallel Routes (@slot convention) render multiple pages simultaneously within one layout. The classic use case: a dashboard with independently streaming panels.
app/
dashboard/
layout.tsx ← Renders @overview + @activity + @metrics
page.tsx ← Default slot content
@overview/
page.tsx ← Overview panel
@activity/
page.tsx ← Activity feed panel
@metrics/
page.tsx ← Metrics panel
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
overview,
activity,
metrics,
}: {
children: React.ReactNode;
overview: React.ReactNode;
activity: React.ReactNode;
metrics: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12 gap-6 p-6">
{/* Main content */}
<div className="col-span-8">{children}</div>
{/* Parallel slots — each loads independently */}
<aside className="col-span-4 space-y-6">
<Suspense fallback={<OverviewSkeleton />}>
{overview}
</Suspense>
<Suspense fallback={<MetricsSkeleton />}>
{metrics}
</Suspense>
</aside>
{/* Activity feed — full width below */}
<div className="col-span-12">
<Suspense fallback={<ActivitySkeleton />}>
{activity}
</Suspense>
</div>
</div>
);
}
// app/dashboard/@metrics/page.tsx — loads independently:
import { db } from '@/lib/db';
import { auth } from '@/auth';
export default async function MetricsSlot() {
const session = await auth();
// This slow query doesn't block the rest of the dashboard:
const metrics = await db.event.aggregate({
where: { userId: session!.user.id, createdAt: { gte: thirtyDaysAgo } },
_count: { id: true },
_sum: { revenue: true },
});
return (
<div className="rounded-lg border p-4">
<h3 className="font-semibold">Last 30 Days</h3>
<p>{metrics._count.id} events</p>
<p>${(metrics._sum.revenue ?? 0) / 100} revenue</p>
</div>
);
}
3. Intercepting Routes: Modal with a Real URL
The best pattern for settings pages, upgrade modals, and detail views. The user sees a modal, but the URL changes — so they can share the link or refresh to get the full page.
app/
dashboard/
billing/
page.tsx ← Full billing page (accessed directly)
@modal/
(.)billing/
page.tsx ← Modal version (intercepted from /billing)
default.tsx ← null (no modal by default)
layout.tsx ← Renders @modal slot
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<div>
{children}
{modal} {/* Modal renders on top of current page */}
</div>
);
}
// app/dashboard/@modal/(.)billing/page.tsx — the modal version:
import { BillingContent } from '@/components/billing-content';
import { Modal } from '@/components/ui/modal';
export default function BillingModal() {
return (
<Modal>
{/* Same content as full page, just in a modal wrapper */}
<BillingContent />
</Modal>
);
}
// components/ui/modal.tsx — closes on back navigation:
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
}, []);
return (
<dialog
ref={dialogRef}
onClose={() => router.back()}
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onClick={(e) => { if (e.target === dialogRef.current) router.back(); }}
>
<div className="bg-white rounded-lg p-6 max-w-2xl mx-auto mt-20" onClick={(e) => e.stopPropagation()}>
{children}
<button onClick={() => router.back()} className="absolute top-4 right-4">
✕
</button>
</div>
</dialog>
);
}
SaaS use cases for intercepting routes:
/settings/billing— opens as modal from dashboard, full page when accessed directly/users/[id]— user detail modal in admin panel/upgrade— upgrade prompt modal with shareable URL/onboarding/step/[n]— onboarding step modals that can be deep-linked
4. Streaming + Suspense: Progressive Dashboard Loading
Never block an entire page on the slowest query. Stream data in as it's ready.
// app/dashboard/page.tsx — shell loads instantly, panels stream in:
import { Suspense } from 'react';
import { RevenueChart, RevenueChartSkeleton } from './revenue-chart';
import { RecentActivity, ActivitySkeleton } from './recent-activity';
import { QuickStats, StatsSkeleton } from './quick-stats';
export default function DashboardPage() {
return (
<div className="space-y-6">
{/* This text renders immediately — no waiting */}
<h1 className="text-2xl font-bold">Dashboard</h1>
{/* Stats load fast (simple count queries): */}
<Suspense fallback={<StatsSkeleton />}>
<QuickStats />
</Suspense>
{/* Chart loads slower (aggregation query): */}
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* Activity loads slowest (joins multiple tables): */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
// components/dashboard/revenue-chart.tsx — async server component:
import { db } from '@/lib/db';
import { auth } from '@/auth';
export async function RevenueChart() {
const session = await auth();
// This slow aggregation doesn't block other components:
const data = await db.$queryRaw`
SELECT
DATE_TRUNC('day', created_at) as day,
SUM(amount) as revenue
FROM payments
WHERE user_id = ${session!.user.id}
AND created_at > NOW() - INTERVAL '30 days'
GROUP BY 1
ORDER BY 1
`;
return <LineChart data={data as any[]} />;
}
export function RevenueChartSkeleton() {
return (
<div className="h-64 rounded-lg bg-gray-100 animate-pulse" />
);
}
5. Route Groups for SaaS Layout Variants
Route groups ((group)) let you have multiple layouts without affecting the URL.
app/
(marketing)/ ← Public pages: no sidebar
page.tsx → /
pricing/page.tsx → /pricing
blog/[slug]/page.tsx → /blog/...
(auth)/ ← Auth pages: centered layout
login/page.tsx → /login
signup/page.tsx → /signup
(app)/ ← Protected app: sidebar + nav
layout.tsx ← Checks auth, shows sidebar
dashboard/page.tsx → /dashboard
settings/page.tsx → /settings
// app/(app)/layout.tsx — auth-protected layout with sidebar:
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
import { Sidebar } from '@/components/sidebar';
import { TopNav } from '@/components/top-nav';
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
return (
<div className="flex h-screen">
<Sidebar user={session.user} />
<div className="flex flex-col flex-1 overflow-hidden">
<TopNav user={session.user} />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
);
}
How Boilerplates Handle These Patterns
| Pattern | ShipFast | T3 Stack | Supastarter | Epic Stack |
|---|---|---|---|---|
| Server Actions | ✅ Primary pattern | ❌ Uses tRPC | ✅ Mixed | ✅ Uses Conform |
| Parallel Routes | ❌ Not used | ❌ | Limited | ❌ |
| Intercepting Routes | ❌ | ❌ | ❌ | ❌ |
| Streaming/Suspense | Partial | Partial | ✅ | ✅ |
| Route Groups | ✅ | ✅ | ✅ | N/A (Remix) |
The gap: Parallel Routes and Intercepting Routes are almost universally absent from boilerplates — but they're the patterns that make SaaS UIs feel polished. You'll need to add them yourself.
Compare boilerplates and their App Router implementations at StarterPick.