Skip to main content
Shipr uses Next.js 15 App Router with route groups to organize pages by function and apply different layouts based on the user’s context.

Route Groups Overview

Route groups use folder names wrapped in parentheses (name) to organize routes without affecting the URL structure. This allows you to:
  • Apply different layouts to different sections
  • Keep related pages organized
  • Share loading states and error boundaries
Key concept: (dashboard)/settings/page.tsx becomes /settings, not /dashboard/settings

Route Structure

src/app/
├── (auth)/              → /sign-in, /sign-up
├── (dashboard)/         → /dashboard, /onboarding
├── (legal)/             → /privacy, /terms, /cookies
├── (marketing)/         → /, /features, /pricing, /blog
├── api/                 → /api/health, /api/email, /api/chat
└── waitlist/            → /waitlist

Route Group Details

(marketing) — Public Pages

Location: src/app/(marketing)/
Layout: src/app/(marketing)/layout.tsx
Features: Header navigation, footer, no auth required
Routes:
PathFilePurpose
/(marketing)/page.tsxLanding page
/features(marketing)/features/page.tsxFeatures page
/pricing(marketing)/pricing/page.tsxPricing plans
/about(marketing)/about/page.tsxAbout page
/docs(marketing)/docs/page.tsxDocs redirect
/blog(marketing)/blog/page.tsxBlog index
/blog/[slug](marketing)/blog/[slug]/page.tsxBlog post detail
Layout structure:
// src/app/(marketing)/layout.tsx
import { HeroHeader } from "@/components/header";
import { Footer } from "@/components/footer-1";

export default function MarketingLayout({ children }) {
  return (
    <>
      <HeroHeader />
      <main>{children}</main>
      <Footer />
    </>
  );
}

(auth) — Authentication Pages

Location: src/app/(auth)/
Layout: src/app/(auth)/layout.tsx
Features: Centered layout, Clerk components
Routes:
PathFilePurpose
/sign-in(auth)/sign-in/[[...sign-in]]/page.tsxClerk sign-in UI
/sign-up(auth)/sign-up/[[...sign-up]]/page.tsxClerk sign-up UI
Catch-all routes: The [[...sign-in]] syntax creates optional catch-all routes, allowing Clerk to handle sub-paths like /sign-in/verify-email. Layout structure:
// src/app/(auth)/layout.tsx
export default function AuthLayout({ children }) {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-md">
        {children}
      </div>
    </div>
  );
}
Sign-in page:
// src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from "@clerk/nextjs";

export default function SignInPage() {
  return <SignIn />;
}

(dashboard) — Protected Pages

Location: src/app/(dashboard)/
Layout: src/app/(dashboard)/layout.tsx
Features: Sidebar navigation, authentication required, user sync
Routes:
PathFilePurpose
/dashboard(dashboard)/dashboard/page.tsxMain dashboard
/dashboard/chat(dashboard)/dashboard/chat/page.tsxAI chat interface
/dashboard/files(dashboard)/dashboard/files/page.tsxFile management
/onboarding(dashboard)/onboarding/page.tsxNew user onboarding
Layout structure:
// src/app/(dashboard)/layout.tsx
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
import { useSyncUser } from "@/hooks/use-sync-user";

export default function DashboardLayout({ children }) {
  return (
    <DashboardShell>
      {children}
    </DashboardShell>
  );
}
DashboardShell component:
// src/components/dashboard/dashboard-shell.tsx
import { useSyncUser } from "@/hooks/use-sync-user";
import { useOnboarding } from "@/hooks/use-onboarding";
import { Sidebar } from "@/components/dashboard/sidebar";
import { TopNav } from "@/components/dashboard/top-nav";

export function DashboardShell({ children }) {
  const { isLoaded, user } = useSyncUser();    // Sync Clerk → Convex
  useOnboarding();                              // Redirect if onboarding incomplete
  
  if (!isLoaded) return <LoadingSpinner />;
  
  return (
    <div className="flex">
      <Sidebar />
      <div className="flex-1">
        <TopNav user={user} />
        <main>{children}</main>
      </div>
    </div>
  );
}
Location: src/app/(legal)/
Layout: src/app/(legal)/layout.tsx
Features: Minimal layout, static content
Routes:
PathFilePurpose
/privacy(legal)/privacy/page.tsxPrivacy policy
/terms(legal)/terms/page.tsxTerms of service
/cookies(legal)/cookies/page.tsxCookie policy
Layout structure:
// src/app/(legal)/layout.tsx
export default function LegalLayout({ children }) {
  return (
    <div className="mx-auto max-w-4xl px-6 py-12">
      {children}
    </div>
  );
}

waitlist — Standalone Page

Location: src/app/waitlist/page.tsx
Path: /waitlist
Layout: Uses root layout (no route group)
Pages outside route groups use the root layout directly.

Dynamic Routes

Blog Post Detail

Path: /blog/[slug]
File: src/app/(marketing)/blog/[slug]/page.tsx
import { BLOG_POSTS, getBlogPostBySlug } from "@/lib/blog";
import { notFound } from "next/navigation";

interface Props {
  params: { slug: string };
}

export async function generateStaticParams() {
  return BLOG_POSTS.map((post) => ({
    slug: post.slug,
  }));
}

export default function BlogPostPage({ params }: Props) {
  const post = getBlogPostBySlug(params.slug);
  
  if (!post) {
    notFound();
  }
  
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}
URL examples:
  • /blog/getting-startedparams.slug = "getting-started"
  • /blog/nextjs-tipsparams.slug = "nextjs-tips"

API Routes

Location: src/app/api/
Convention: route.ts files export HTTP method handlers

Health Check

File: src/app/api/health/route.ts
Path: GET /api/health
export async function GET(req: Request) {
  return Response.json({
    status: "ok",
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
  });
}

Email API

File: src/app/api/email/route.ts
Path: POST /api/email
Auth: Clerk middleware
import { auth } from "@clerk/nextjs/server";
import { sendEmail, welcomeEmail } from "@/lib/emails";

export async function POST(req: Request) {
  const { userId } = await auth();
  
  if (!userId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
  
  const body = await req.json();
  const { subject, html } = welcomeEmail({ name: body.name });
  
  await sendEmail({ to: body.email, subject, html });
  
  return Response.json({ success: true });
}

Chat API

File: src/app/api/chat/route.ts
Path: POST /api/chat
Features: Streaming responses via Vercel AI SDK
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { auth } from "@clerk/nextjs/server";

export async function POST(req: Request) {
  const { userId } = await auth();
  
  if (!userId) {
    return new Response("Unauthorized", { status: 401 });
  }
  
  const { messages } = await req.json();
  
  const result = streamText({
    model: openai("gpt-4.1-mini"),
    messages,
  });
  
  return result.toDataStreamResponse();
}
Use Next.js Link for client-side navigation:
import Link from "next/link";
import { ROUTES } from "@/lib/constants";

export function Header() {
  return (
    <nav>
      <Link href={ROUTES.marketing.home}>Home</Link>
      <Link href={ROUTES.marketing.features}>Features</Link>
      <Link href={ROUTES.marketing.pricing}>Pricing</Link>
      <Link href={ROUTES.dashboard.home}>Dashboard</Link>
    </nav>
  );
}

Programmatic Navigation

Use useRouter for navigation in event handlers:
import { useRouter } from "next/navigation";

export function LoginButton() {
  const router = useRouter();
  
  const handleLogin = async () => {
    await performLogin();
    router.push("/dashboard");
  };
  
  return <button onClick={handleLogin}>Login</button>;
}

Redirect

Use redirect() in Server Components:
import { redirect } from "next/navigation";
import { auth } from "@clerk/nextjs/server";

export default async function ProtectedPage() {
  const { userId } = await auth();
  
  if (!userId) {
    redirect("/sign-in");
  }
  
  return <Dashboard />;
}

Route Protection

Clerk Middleware

Protect routes via middleware.ts:
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isPublicRoute = createRouteMatcher([
  "/",
  "/features",
  "/pricing",
  "/blog(.*)",
  "/sign-in(.*)",
  "/sign-up(.*)",
  "/api/health",
]);

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) {
    await auth.protect();
  }
});

export const config = {
  matcher: [
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    "/(api|trpc)(.*)",
  ],
};
Protected routes: /dashboard, /onboarding, /dashboard/chat, /dashboard/files
Public routes: /, /features, /pricing, /blog, /sign-in, /sign-up

Client-side Protection

Use useAuth() hook for conditional rendering:
import { useAuth } from "@clerk/nextjs";
import { redirect } from "next/navigation";

export function ProtectedContent() {
  const { isLoaded, userId } = useAuth();
  
  if (!isLoaded) return <LoadingSpinner />;
  if (!userId) redirect("/sign-in");
  
  return <Dashboard />;
}

Onboarding Flow

Shipr includes automatic onboarding redirection for new users: Hook: src/hooks/use-onboarding.ts
import { useRouter } from "next/navigation";
import { useQuery } from "convex/react";
import { api } from "@convex/_generated/api";

export function useOnboarding() {
  const router = useRouter();
  const status = useQuery(api.users.getOnboardingStatus);
  
  if (status && !status.completed) {
    router.push("/onboarding");
  }
}
Usage in DashboardShell:
import { useOnboarding } from "@/hooks/use-onboarding";

export function DashboardShell({ children }) {
  useOnboarding();  // Auto-redirect to /onboarding if incomplete
  
  return (
    <div>
      <Sidebar />
      <main>{children}</main>
    </div>
  );
}

SEO & Metadata

Static Metadata

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Features | Shipr",
  description: "Explore powerful features of Shipr SaaS boilerplate",
};

export default function FeaturesPage() {
  return <Features />;
}

Dynamic Metadata

import type { Metadata } from "next";
import { getBlogPostBySlug } from "@/lib/blog";

interface Props {
  params: { slug: string };
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = getBlogPostBySlug(params.slug);
  
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.publishedAt,
    },
  };
}

Generated Routes

Sitemap

File: src/app/sitemap.ts
Path: /sitemap.xml
import type { MetadataRoute } from "next";
import { BLOG_POSTS } from "@/lib/blog";

export default function sitemap(): MetadataRoute.Sitemap {
  const blogPosts = BLOG_POSTS.map((post) => ({
    url: `https://yourdomain.com/blog/${post.slug}`,
    lastModified: new Date(post.publishedAt),
  }));
  
  return [
    { url: "https://yourdomain.com", lastModified: new Date() },
    { url: "https://yourdomain.com/features", lastModified: new Date() },
    { url: "https://yourdomain.com/pricing", lastModified: new Date() },
    ...blogPosts,
  ];
}

Robots.txt

File: src/app/robots.ts
Path: /robots.txt
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: ["/dashboard/", "/onboarding/"],
    },
    sitemap: "https://yourdomain.com/sitemap.xml",
  };
}

Next Steps

Architecture

Learn about the tech stack and architectural decisions

Project Structure

Explore the directory structure and file organization

Providers

Deep dive into the provider stack configuration

Authentication

Set up and customize Clerk authentication

Build docs developers (and LLMs) love