Route Groups: Organizing Your Next.js App
Clean architecture through route organization.
Next.js 16's App Router introduces route groupsβa powerful way to organize your application without affecting URLs. Fabrk uses this pattern extensively to separate public marketing pages, authenticated app pages, authentication flows, and API routes, each with their own layouts, loading states, and error boundaries.
The Organization Challenge
Without route groups, Next.js apps often become disorganized:
app/
βββ page.tsx # Landing page
βββ dashboard/
β βββ page.tsx # Dashboard (needs auth)
βββ login/
β βββ page.tsx # Login page
βββ pricing/
β βββ page.tsx # Public pricing
βββ settings/
β βββ page.tsx # User settings (needs auth)
βββ layout.tsx # One layout for everything?
Problems with this structure:
- One layout file serves completely different purposes
- No clear separation between public and authenticated pages
- Authentication logic scattered across pages
- Different navigation requirements mixed together
Route Groups
Next.js route groups organize pages without affecting URLs:
src/app/
βββ (public)/ # Public pages (no auth required)
βββ (platform)/ # Authenticated app
βββ (auth)/ # Auth pages
βββ api/ # API routes
The parentheses create groups without adding URL segments. A page at (public)/pricing/page.tsx serves the URL /pricing, not /(public)/pricing.
Fabrk's Route Structure
src/app/
βββ (public)/ # Marketing & public content
β βββ page.tsx # Landing page (/)
β βββ pricing/
β β βββ page.tsx # Pricing (/pricing)
β βββ blog/
β β βββ page.tsx # Blog list (/blog)
β β βββ [slug]/
β β βββ page.tsx # Blog post (/blog/slug)
β βββ features/
β β βββ page.tsx # Features (/features)
β βββ docs/
β β βββ [...slug]/
β β βββ page.tsx # Documentation (/docs/*)
β βββ layout.tsx # Public layout
β
βββ (platform)/ # Authenticated application
β βββ dashboard/
β β βββ page.tsx # Dashboard (/dashboard)
β βββ settings/
β β βββ page.tsx # Settings index (/settings)
β β βββ profile/
β β β βββ page.tsx # Profile (/settings/profile)
β β βββ billing/
β β β βββ page.tsx # Billing (/settings/billing)
β β βββ team/
β β βββ page.tsx # Team (/settings/team)
β βββ projects/
β β βββ page.tsx # Projects list (/projects)
β β βββ [id]/
β β βββ page.tsx # Project detail (/projects/123)
β βββ admin/
β β βββ page.tsx # Admin overview (/admin)
β β βββ users/
β β β βββ page.tsx # User management (/admin/users)
β β βββ layout.tsx # Admin-specific layout
β βββ layout.tsx # Platform layout
β
βββ (auth)/ # Authentication flows
β βββ login/
β β βββ page.tsx # Login (/login)
β βββ register/
β β βββ page.tsx # Register (/register)
β βββ forgot-password/
β β βββ page.tsx # Forgot password
β βββ reset-password/
β β βββ page.tsx # Reset password
β βββ verify-email/
β β βββ page.tsx # Email verification
β βββ layout.tsx # Auth layout
β
βββ api/ # API routes
β βββ auth/
β β βββ [...nextauth]/
β β βββ route.ts # NextAuth handler
β βββ stripe/
β β βββ checkout/
β β β βββ route.ts # Stripe checkout
β β βββ webhook/
β β βββ route.ts # Stripe webhooks
β βββ users/
β β βββ route.ts # User CRUD
β βββ projects/
β β βββ route.ts # Projects CRUD
β β βββ [id]/
β β βββ route.ts # Single project
β βββ webhooks/
β βββ route.ts # Generic webhooks
β
βββ globals.css # Global styles
βββ layout.tsx # Root layout
Route Group Layouts
Each group has its own layout, serving different purposes:
Root Layout
The root layout wraps everything:
// app/layout.tsximport { JetBrains_Mono } from 'next/font/google';import { ThemeProvider } from '@/components/theme-provider';import '@/app/globals.css';const mono = JetBrains_Mono({subsets: ['latin'],variable: '--font-mono',});export default function RootLayout({children,}: {children: React.ReactNode;}) {return (<html lang="en" className={mono.variable} suppressHydrationWarning><body className="font-mono antialiased"><ThemeProvider>{children}</ThemeProvider></body></html>);}
Public Layout
Marketing pages with header and footer:
// app/(public)/layout.tsximport { Header } from '@/components/marketing/header';import { Footer } from '@/components/marketing/footer';export default function PublicLayout({children,}: {children: React.ReactNode;}) {return (<><Header /><main className="min-h-screen">{children}</main><Footer /></>);}
Platform Layout
Authenticated app with sidebar and session check:
// app/(platform)/layout.tsximport { auth } from '@/lib/auth';import { redirect } from 'next/navigation';import { Sidebar } from '@/components/dashboard/sidebar';import { TopNav } from '@/components/dashboard/top-nav';export default async function PlatformLayout({children,}: {children: React.ReactNode;}) {const session = await auth();if (!session) {redirect('/login');}return (<div className="flex h-screen"><Sidebar user={session.user} /><div className="flex-1 flex flex-col overflow-hidden"><TopNav user={session.user} /><main className="flex-1 overflow-y-auto p-6">{children}</main></div></div>);}
Auth Layout
Centered card layout for authentication:
// app/(auth)/layout.tsximport { auth } from '@/lib/auth';import { redirect } from 'next/navigation';import { Logo } from '@/components/ui/logo';export default async function AuthLayout({children,}: {children: React.ReactNode;}) {const session = await auth();// Redirect authenticated users to dashboardif (session) {redirect('/dashboard');}return (<div className="min-h-screen flex items-center justify-center bg-background"><div className="w-full max-w-md space-y-6"><div className="text-center"><Logo className="mx-auto h-12 w-12" /><p className="mt-2 text-muted-foreground font-mono text-xs">[ AUTHENTICATION ]</p></div>{children}</div></div>);}
Benefits of Route Groups
1. Separation of Concerns
Each route group has a clear purpose:
(public)- Anyone can access, marketing focus(platform)- Must be logged in, app functionality(auth)- Login/register flows, redirect when authenticated
2. Different Layouts Per Group
| Group | Layout Features | |-------|-----------------| | (public) | Marketing header, footer, full-width | | (platform) | Sidebar, top nav, authenticated user context | | (auth) | Centered card, minimal chrome, logo |
3. Clean URLs
Groups don't appear in URLs:
/dashboardnot/(platform)/dashboard/blognot/(public)/blog/loginnot/(auth)/login
4. Colocated Features
Related files stay together:
(platform)/
βββ settings/
β βββ page.tsx # Main page
β βββ layout.tsx # Settings-specific layout
β βββ loading.tsx # Settings loading state
β βββ error.tsx # Settings error boundary
β βββ profile/
β βββ page.tsx
5. Middleware Targeting
Apply middleware to specific groups:
// middleware.tsimport { auth } from '@/lib/auth';import { NextResponse } from 'next/server';export async function middleware(request: Request) {const { pathname } = new URL(request.url);// Only check auth for platform routesif (pathname.startsWith('/dashboard') ||pathname.startsWith('/settings') ||pathname.startsWith('/projects') ||pathname.startsWith('/admin')) {const session = await auth();if (!session) {return NextResponse.redirect(new URL('/login', request.url));}// Admin routes need admin roleif (pathname.startsWith('/admin') && session.user.role !== 'admin') {return NextResponse.redirect(new URL('/dashboard', request.url));}}return NextResponse.next();}export const config = {matcher: ['/dashboard/:path*','/settings/:path*','/projects/:path*','/admin/:path*',],};
Nested Layouts
Layouts can be nested for more specific customization:
(platform)/
βββ layout.tsx # Main platform layout (sidebar)
βββ dashboard/
β βββ page.tsx
βββ settings/
β βββ layout.tsx # Settings layout (adds tabs)
β βββ page.tsx # General settings
β βββ profile/
β β βββ page.tsx # Profile settings
β βββ billing/
β β βββ page.tsx # Billing settings
β βββ team/
β βββ page.tsx # Team settings
βββ admin/
βββ layout.tsx # Admin layout (different sidebar)
βββ page.tsx # Admin overview
βββ users/
βββ page.tsx # User management
Settings pages get both the platform layout AND the settings layout:
// app/(platform)/settings/layout.tsximport { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';import Link from 'next/link';export default function SettingsLayout({children,}: {children: React.ReactNode;}) {return (<div className="space-y-6"><div><h1 className="font-mono text-2xl font-semibold uppercase">SETTINGS</h1><p className="text-muted-foreground text-sm">Manage your account and preferences</p></div><Tabs defaultValue="general" className="w-full"><TabsList><TabsTrigger value="general" asChild><Link href="/settings">General</Link></TabsTrigger><TabsTrigger value="profile" asChild><Link href="/settings/profile">Profile</Link></TabsTrigger><TabsTrigger value="billing" asChild><Link href="/settings/billing">Billing</Link></TabsTrigger><TabsTrigger value="team" asChild><Link href="/settings/team">Team</Link></TabsTrigger></TabsList></Tabs><div className="border-t border-border pt-6">{children}</div></div>);}
Loading States
Each group (and nested route) can have its own loading UI:
// app/(platform)/loading.tsximport { Skeleton } from '@/components/ui/skeleton';export default function PlatformLoading() {return (<div className="space-y-6"><Skeleton className="h-8 w-48" /><div className="grid grid-cols-4 gap-4"><Skeleton className="h-24" /><Skeleton className="h-24" /><Skeleton className="h-24" /><Skeleton className="h-24" /></div><Skeleton className="h-64" /></div>);}// app/(platform)/dashboard/loading.tsx// More specific loading state for dashboardexport default function DashboardLoading() {return (<div className="space-y-6"><div className="flex items-center justify-between"><Skeleton className="h-8 w-32" /><Skeleton className="h-10 w-24" /></div><div className="grid grid-cols-4 gap-4">{[...Array(4)].map((_, i) => (<Skeleton key={i} className="h-32" />))}</div><div className="grid grid-cols-2 gap-4"><Skeleton className="h-80" /><Skeleton className="h-80" /></div></div>);}
Error Handling
Each group can have error boundaries:
// app/(platform)/error.tsx'use client';import { useEffect } from 'react';import { Card, CardContent, CardHeader } from '@/components/ui/card';import { Button } from '@/components/ui/button';import { mode } from '@/design-system';import { cn } from '@/lib/utils';export default function PlatformError({error,reset,}: {error: Error & { digest?: string };reset: () => void;}) {useEffect(() => {// Log error to monitoring serviceconsole.error('Platform error:', error);}, [error]);return (<div className="flex items-center justify-center min-h-[400px]"><Card className={cn('max-w-md text-center', mode.radius)}><CardHeader><span className="text-4xl font-mono">[ ERROR ]</span></CardHeader><CardContent className="space-y-4"><p className="text-muted-foreground">Something went wrong loading this page.</p><p className="text-sm font-mono text-destructive">{error.message}</p><div className="flex gap-2 justify-center"><Button onClick={reset} variant="outline">> TRY AGAIN</Button><Button asChild><a href="/dashboard">> GO TO DASHBOARD</a></Button></div></CardContent></Card></div>);}
Not Found Pages
Custom 404 pages per group:
// app/(platform)/not-found.tsximport Link from 'next/link';import { Card, CardContent } from '@/components/ui/card';import { Button } from '@/components/ui/button';export default function PlatformNotFound() {return (<div className="flex items-center justify-center min-h-[400px]"><Card className="max-w-md text-center p-8"><CardContent className="space-y-4"><span className="text-6xl font-mono font-bold">404</span><p className="text-xl font-mono uppercase">[ PAGE NOT FOUND ]</p><p className="text-muted-foreground">The page you're looking for doesn't exist in your dashboard.</p><Button asChild><Link href="/dashboard">> BACK TO DASHBOARD</Link></Button></CardContent></Card></div>);}// app/(public)/not-found.tsx// Different not found for public pagesexport default function PublicNotFound() {return (<div className="flex items-center justify-center min-h-screen"><div className="text-center space-y-4"><span className="text-6xl font-mono font-bold">404</span><p className="text-xl font-mono uppercase">[ PAGE NOT FOUND ]</p><p className="text-muted-foreground">This page doesn't exist. Let's get you back on track.</p><div className="flex gap-2 justify-center"><Button asChild variant="outline"><Link href="/">> HOME</Link></Button><Button asChild><Link href="/pricing">> VIEW PRICING</Link></Button></div></div></div>);}
Shared Components
Components shared across groups live in src/components/:
src/components/
βββ ui/ # UI primitives (all groups use)
β βββ button.tsx
β βββ card.tsx
β βββ input.tsx
β βββ ...
βββ charts/ # Chart components (platform, admin)
β βββ bar-chart.tsx
β βββ ...
βββ marketing/ # (public) group specific
β βββ header.tsx
β βββ footer.tsx
β βββ hero.tsx
β βββ pricing-table.tsx
βββ dashboard/ # (platform) group specific
β βββ sidebar.tsx
β βββ top-nav.tsx
β βββ stats-card.tsx
β βββ activity-feed.tsx
βββ auth/ # (auth) group specific
β βββ sign-in-form.tsx
β βββ sign-up-form.tsx
β βββ social-auth.tsx
βββ admin/ # Admin-specific components
βββ user-table.tsx
βββ system-health.tsx
Parallel Routes
For complex layouts with multiple simultaneous views:
(platform)/
βββ dashboard/
βββ page.tsx # Main dashboard content
βββ @stats/
β βββ page.tsx # Stats panel
β βββ loading.tsx # Stats loading
βββ @activity/
β βββ page.tsx # Activity feed
β βββ loading.tsx # Activity loading
βββ layout.tsx # Combines parallel routes
// app/(platform)/dashboard/layout.tsxexport default function DashboardLayout({children,stats,activity,}: {children: React.ReactNode;stats: React.ReactNode;activity: React.ReactNode;}) {return (<div className="space-y-6">{/* Stats panel - loads independently */}<div className="grid grid-cols-4 gap-4">{stats}</div>{/* Main content */}<div className="grid grid-cols-3 gap-6"><div className="col-span-2">{children}</div>{/* Activity feed - loads independently */}<div>{activity}</div></div></div>);}
Each parallel route loads independently with its own loading state.
Intercepting Routes
For modal patterns that preserve context:
(platform)/
βββ projects/
β βββ page.tsx # Projects list
β βββ [id]/
β βββ page.tsx # Full project page (direct URL)
βββ @modal/
βββ (.)projects/[id]/
βββ page.tsx # Project modal (intercepted)
When clicking a project link from the list, the modal version loads. When navigating directly to /projects/123, the full page loads.
// app/(platform)/@modal/(.)projects/[id]/page.tsximport { Dialog, DialogContent } from '@/components/ui/dialog';import { ProjectDetails } from '@/components/dashboard/project-details';export default async function ProjectModal({params,}: {params: { id: string };}) {const project = await getProject(params.id);return (<Dialog defaultOpen><DialogContent className="max-w-2xl"><ProjectDetails project={project} /></DialogContent></Dialog>);}
API Routes Organization
API routes follow similar organization:
api/
βββ auth/
β βββ [...nextauth]/
β βββ route.ts # NextAuth.js handlers
βββ users/
β βββ route.ts # GET all, POST create
β βββ [id]/
β βββ route.ts # GET one, PUT update, DELETE
βββ projects/
β βββ route.ts # GET all, POST create
β βββ [id]/
β βββ route.ts # GET one, PUT update, DELETE
β βββ members/
β βββ route.ts # Project members
βββ stripe/
β βββ checkout/
β β βββ route.ts # Create checkout session
β βββ webhook/
β βββ route.ts # Handle Stripe webhooks
βββ polar/
β βββ checkout/
β β βββ route.ts # Polar checkout
β βββ webhook/
β βββ route.ts # Polar webhooks
βββ admin/
βββ users/
β βββ route.ts # Admin user management
βββ stats/
βββ route.ts # Admin statistics
API Route Pattern
// app/api/projects/route.tsimport { auth } from '@/lib/auth';import { prisma } from '@/lib/prisma';import { z } from 'zod';const createSchema = z.object({name: z.string().min(1).max(100),description: z.string().optional(),});// GET /api/projectsexport async function GET() {const session = await auth();if (!session?.user) {return Response.json({ error: 'Unauthorized' }, { status: 401 });}const projects = await prisma.project.findMany({where: { organizationId: session.user.organizationId },orderBy: { createdAt: 'desc' },});return Response.json({ projects });}// POST /api/projectsexport async function POST(request: Request) {const session = await auth();if (!session?.user) {return Response.json({ error: 'Unauthorized' }, { status: 401 });}const body = await request.json();const result = createSchema.safeParse(body);if (!result.success) {return Response.json({ error: result.error.issues }, { status: 400 });}const project = await prisma.project.create({data: {...result.data,organizationId: session.user.organizationId,},});return Response.json({ project }, { status: 201 });}
Best Practices
1. Use Route Groups for Access Levels
(public)/ β No authentication required
(platform)/ β Requires authentication
(auth)/ β Auth flow pages
2. Keep Layouts Minimal
Layouts should only contain shared UI. Keep business logic in pages.
// Good - layout handles structure onlyexport default function Layout({ children }) {return (<div className="flex"><Sidebar /><main>{children}</main></div>);}// Bad - layout has too much logicexport default async function Layout({ children }) {const user = await getUser();const notifications = await getNotifications();const preferences = await getPreferences();// Too much happening in layout}
3. Colocate Related Files
Keep related files near where they're used:
(platform)/settings/
βββ page.tsx # Main page
βββ layout.tsx # Nested layout
βββ loading.tsx # Loading state
βββ error.tsx # Error boundary
βββ profile/
βββ page.tsx # Child page
4. Use Meaningful Group Names
(marketing)instead of(group1)(dashboard)instead of(app)- Clear names help team members understand structure
5. Document Your Structure
Add a README or comment explaining the organization:
# Route Structure- `(public)/` - Marketing pages, no auth required- `(platform)/` - Main application, requires authentication- `(auth)/` - Authentication flows- `api/` - Backend API endpoints
Migration Tips
Moving from Pages Router or flat structure:
1. Start with Route Groups
$# Create structure$mkdir -p src/app/(public) src/app/(platform) src/app/(auth)
2. Move Pages Incrementally
$# Move marketing pages$mv src/app/page.tsx src/app/(public)/page.tsx$mv src/app/pricing src/app/(public)/pricing$# Move app pages$mv src/app/dashboard src/app/(platform)/dashboard$mv src/app/settings src/app/(platform)/settings$# Move auth pages$mv src/app/login src/app/(auth)/login$mv src/app/register src/app/(auth)/register
3. Create Group Layouts
Add layout.tsx to each group with appropriate UI.
4. Test All Routes
Ensure URLs still work:
/β Landing page/dashboardβ Dashboard/loginβ Login page
Summary
Route groups provide clean architecture through organization:
- Separate concerns by access level and purpose
- Different layouts for different sections
- Clean URLs without group segments
- Colocated files for better maintainability
- Independent loading/error states per section
Structure your app logically, and Next.js handles the rest.