Skip to main content

Next.js 15 App Router Patterns Every SaaS Needs in 2026

·StarterPick Team
next-jsapp-routersaasserver-actionsreact2026

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 /api routes, 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

PatternShipFastT3 StackSupastarterEpic Stack
Server Actions✅ Primary pattern❌ Uses tRPC✅ Mixed✅ Uses Conform
Parallel Routes❌ Not usedLimited
Intercepting Routes
Streaming/SuspensePartialPartial
Route GroupsN/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.

Comments