Skip to main content
ZeroStarter uses Next.js App Router with a file-system based routing approach. Routes are organized using route groups for clean URL structures and shared layouts.

Route Structure

The application follows this routing architecture:
src/app/
├── (content)/              # Content route group
│   ├── blog/
│   │   ├── [[...slug]]/   # Catch-all blog posts
│   │   │   └── page.tsx
│   │   └── layout.tsx     # Blog layout
│   └── docs/
│       ├── [[...slug]]/   # Catch-all docs pages
│       │   └── page.tsx
│       └── layout.tsx     # Docs layout
├── (protected)/           # Protected route group
│   ├── dashboard/
│   │   └── page.tsx
│   └── layout.tsx         # Protected layout with auth
├── api/                   # API routes
│   ├── og/               # Open Graph images
│   └── search/           # Search endpoint
├── hire/
│   └── page.tsx
├── layout.tsx            # Root layout
└── page.tsx              # Home page

Route Groups

Route groups organize routes without affecting the URL structure. They’re created using parentheses (group-name).

Content Route Group

The (content) group contains documentation and blog pages:
src/app/(content)/docs/layout.tsx
import { DocsLayout } from "fumadocs-ui/layouts/docs"
import { RootProvider } from "fumadocs-ui/provider/next"
import { SidebarDocsContent, SidebarDocsFooter, SidebarDocsSearch } from "@/components/sidebar/docs"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarHeader,
  SidebarProvider,
  SidebarRail,
} from "@/components/ui/sidebar"
import { SidebarTrigger } from "@/components/zeroui/sidebar-trigger"
import { baseOptions } from "@/lib/fumadocs"
import { docsSource } from "@/lib/source"

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <SidebarProvider>
      <Sidebar className="md:pt-12" collapsible="offcanvas">
        <SidebarHeader className="mt-2.5">
          <SidebarDocsSearch />
        </SidebarHeader>
        <SidebarContent>
          <SidebarDocsContent />
        </SidebarContent>
        <SidebarFooter className="border-t">
          <SidebarDocsFooter />
        </SidebarFooter>
        <SidebarRail />
      </Sidebar>
      <main>
        <SidebarTrigger className="md:bg-sidebar! hover:md:bg-sidebar-accent! fixed right-0 bottom-0 mr-6 mb-18 h-8 cursor-pointer border md:right-auto md:mb-48 md:rounded-l-none md:border-l-0">
          <span>Docs</span>
        </SidebarTrigger>
        <RootProvider>
          <DocsLayout
            nav={{ enabled: false }}
            sidebar={{ enabled: false }}
            tree={docsSource.pageTree}
            {...baseOptions()}
          >
            {children}
          </DocsLayout>
        </RootProvider>
      </main>
    </SidebarProvider>
  )
}
URL: /docs/getting-started (not /content/docs/getting-started)

Protected Route Group

The (protected) group requires authentication:
src/app/(protected)/layout.tsx
import { cookies } from "next/headers"
import Link from "next/link"
import { redirect } from "next/navigation"
import {
  SidebarDashboardOrgSwitcher,
  SidebarDashboardUserActions,
} from "@/components/sidebar/dashboard"
import { Badge } from "@/components/ui/badge"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarHeader,
  SidebarProvider,
  SidebarRail,
} from "@/components/ui/sidebar"
import { SidebarTrigger } from "@/components/zeroui/sidebar-trigger"
import { auth } from "@/lib/auth"
import { config } from "@/lib/config"

export default async function Layout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies()
  const sidebarStateCookie = cookieStore.get("sidebar_state")?.value
  const defaultOpen = sidebarStateCookie ? sidebarStateCookie === "true" : true
  const session = await auth.api.getSession()

  // Redirect if not authenticated
  if (!session?.user) redirect("/")

  return (
    <SidebarProvider defaultOpen={defaultOpen}>
      <Sidebar collapsible="icon">
        <SidebarHeader>
          <div className="flex items-center justify-between gap-2 group-data-[collapsible=icon]:mx-auto">
            <Link
              href="/"
              className="flex items-center gap-2 px-1.5 py-2 font-bold group-data-[collapsible=icon]:hidden"
            >
              {config.app.name}
              <Badge variant="secondary" className="text-xs">
                RC
              </Badge>
            </Link>
            <SidebarTrigger className="bg-sidebar cursor-pointer border" />
          </div>
          <SidebarDashboardOrgSwitcher />
        </SidebarHeader>
        <SidebarContent>{/* Content Goes Here */}</SidebarContent>
        <SidebarFooter>
          <SidebarDashboardUserActions user={session.user} />
        </SidebarFooter>
        <SidebarRail />
      </Sidebar>
      <main>{children}</main>
    </SidebarProvider>
  )
}
Features:
  • Session check with redirect
  • Persistent sidebar state via cookies
  • User-specific UI elements

Catch-All Routes

Docs and blog use optional catch-all routes [[...slug]]:
src/app/(content)/docs/[[...slug]]/page.tsx
import { notFound } from "next/navigation"
import { docsSource } from "@/lib/source"

interface PageProps {
  params: Promise<{ slug?: string[] }>
}

export default async function Page({ params }: PageProps) {
  const { slug } = await params
  const page = docsSource.getPage(slug)

  if (!page) notFound()

  return (
    <article>
      {/* Render page content */}
    </article>
  )
}
URL Examples:
  • /docs - matches with slug = undefined
  • /docs/getting-started - matches with slug = ['getting-started']
  • /docs/frontend/routing - matches with slug = ['frontend', 'routing']

API Routes

API routes handle server-side logic:

Search Endpoint

src/app/api/search/route.ts
import { createSearchAPI } from "fumadocs-core/search/server"
import { docsSource } from "@/lib/source"

export const { GET } = createSearchAPI("advanced", {
  indexes: docsSource.getPages().map((page) => ({
    title: page.data.title,
    description: page.data.description,
    url: page.url,
    id: page.url,
    structuredData: page.data.structuredData,
  })),
})

Open Graph Image Generation

src/app/api/og/home/route.tsx
import { ImageResponse } from "@takumi-rs/image-response"
import { NextRequest } from "next/server"

export async function GET(request: NextRequest) {
  return new ImageResponse(
    (
      <div
        style={{
          height: "100%",
          width: "100%",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          backgroundColor: "#fff",
        }}
      >
        {/* OG Image Content */}
      </div>
    ),
    {
      width: 1200,
      height: 630,
    },
  )
}

Layouts

Root Layout

The root layout wraps the entire application:
src/app/layout.tsx
import type { Metadata } from "next"
import { InnerProvider, OuterProvider } from "@/app/providers"
import { Navbar } from "@/components/navbar/home"
import { config } from "@/lib/config"
import "@/app/globals.css"

export const metadata: Metadata = {
  title: {
    default: `${config.app.name} - ${config.app.tagline}`,
    template: `%s | ${config.app.name}`,
  },
  description: config.app.description,
  // ... other metadata
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <OuterProvider>
      <html lang="en" suppressHydrationWarning>
        <body className="min-h-dvh antialiased">
          <InnerProvider>
            <Navbar />
            {children}
          </InnerProvider>
        </body>
      </html>
    </OuterProvider>
  )
}

Nested Layouts

Layouts can be nested for route-specific UI:
// Applies to all routes
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Navbar />
        {children}
      </body>
    </html>
  )
}
Use Next.js Link for client-side navigation:
import Link from "next/link"

<Link href="/docs">Documentation</Link>

// With prefetching disabled
<Link href="/dashboard" prefetch={false}>
  Dashboard
</Link>

Programmatic Navigation

import { useRouter } from "next/navigation"

function Component() {
  const router = useRouter()

  const handleClick = () => {
    router.push("/dashboard")
  }

  return <button onClick={handleClick}>Go to Dashboard</button>
}

Redirects

Server-side redirects in Server Components:
import { redirect } from "next/navigation"

if (!session?.user) {
  redirect("/")
}

Metadata

Static Metadata

import type { Metadata } from "next"

export const metadata: Metadata = {
  title: "Dashboard",
  description: "Your personal dashboard",
}

Dynamic Metadata

import type { Metadata } from "next"

type Props = {
  params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const page = getPage(slug)

  return {
    title: page.title,
    description: page.description,
  }
}

Route Rewrites

Proxy API requests to the backend:
next.config.ts
const nextConfig: NextConfig = {
  rewrites: async () => {
    return [
      {
        source: "/api/:path*",
        destination: `${env.NEXT_PUBLIC_API_URL}/api/:path*`,
      },
      {
        source: "/api/search",
        destination: `${env.NEXT_PUBLIC_APP_URL}/api/search`,
      },
    ]
  },
}
Result: Requests to /api/health are proxied to the Hono API server.

Best Practices

Keep components as Server Components unless client interactivity is needed. This reduces JavaScript sent to the client.
// Server Component (default)
async function Page() {
  const data = await fetchData()
  return <div>{data.title}</div>
}

// Client Component (when needed)
"use client"
function InteractiveButton() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}
Group related routes together for shared layouts without affecting URLs:
app/
├── (marketing)/
│   ├── about/
│   ├── pricing/
│   └── layout.tsx     # Marketing layout
└── (app)/
    ├── dashboard/
    ├── settings/
    └── layout.tsx       # App layout
Fetch data in parallel for better performance:
async function Page() {
  // Parallel fetching
  const [user, posts] = await Promise.all([
    fetchUser(),
    fetchPosts(),
  ])

  return (
    <div>
      <UserProfile user={user} />
      <PostsList posts={posts} />
    </div>
  )
}
Provide loading and error UIs for better UX:
loading.tsx
export default function Loading() {
  return <div>Loading...</div>
}
error.tsx
"use client"

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

Next Steps

Components

Learn about Shadcn UI components and patterns

Data Fetching

Master TanStack Query for client-side data fetching