StarterApp Docs
Applications

Dashboard App

Authenticated application surface with protected routes, BetterAuth integration, and billing features

The dashboard app serves authenticated traffic at http://localhost:3001 in development. It handles sign-in, OAuth callbacks, billing, account management, and all protected application pages.

Development

Install dependencies

From the monorepo root:

Install dependencies
pnpm install

Configure environment

Dashboard reads from @workspace/env. Key variables:

Environment configuration
DASHBOARD_BASE_URL=http://localhost:3001
APP_BASE_URL=http://localhost:3000
BETTER_AUTH_SECRET=your-secret-key
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
CONVEX_URL=https://your-deployment.convex.cloud

Start the dev server

Start dashboard dev server
pnpm --filter dashboard dev

Dashboard runs on http://localhost:3001

Run both apps

Use pnpm dev from the root to start marketing (3000) and dashboard (3001) together.

App Router Structure

The dashboard uses Next.js 15 App Router with a protected route group:

page.tsx
layout.tsx
layout.tsx
providers.tsx
middleware.ts

All routes require auth

Every page inside (dashboard)/ requires authentication. The layout checks session and redirects unauthenticated users to /sign-in.

Root Layout

The root layout sets metadata and wraps the app with providers:

apps/dashboard/app/layout.tsx
import "./globals.css";
import { brandDescription, brandMetaDescription, brandName } from "@workspace/app-shell";
import { loadServerEnv } from "@workspace/env/server";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import type * as React from "react";
import { Providers } from "./providers";

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  themeColor: [
    { media: "(prefers-color-scheme: light)", color: "white" },
    { media: "(prefers-color-scheme: dark)", color: "black" },
  ],
};

const { APP_BASE_URL } = loadServerEnv();

export const metadata: Metadata = {
  metadataBase: new URL(APP_BASE_URL || "http://localhost:3001"),
  title: { default: brandName, template: `%s | ${brandName}` },
  description: brandMetaDescription,
  robots: { index: false, follow: false }, // No indexing for authenticated pages
};

const inter = Inter({ subsets: ["latin"] });

export default async function RootLayout({
  children,
}: React.PropsWithChildren): Promise<React.JSX.Element> {
  return (
    <html className="size-full min-h-screen" lang="en" suppressHydrationWarning>
      <body className={`${inter.className} size-full`}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Pro Tip

robots: { index: false } prevents search engines from indexing authenticated pages.

Providers

Dashboard providers wrap authentication, Convex, and billing contexts:

apps/dashboard/app/providers.tsx
"use client";

import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { authClient } from "@workspace/auth/client";
import { convexClient } from "@workspace/convex/client";
import { Toaster } from "@workspace/ui/components/sonner";
import { TooltipProvider } from "@workspace/ui/components/tooltip";
import type * as React from "react";
import { AppAutumnProvider } from "~/lib/providers/autumn-provider";
import { ServicesProvider } from "~/lib/providers/services-provider";

export function Providers({
  children,
}: React.PropsWithChildren): React.JSX.Element {
  const content = (
    <ServicesProvider>
      <AppAutumnProvider>
        <TooltipProvider>
          {children}
          <Toaster />
        </TooltipProvider>
      </AppAutumnProvider>
    </ServicesProvider>
  );

  if (!convexClient) {
    return content;
  }

  return (
    <ConvexBetterAuthProvider authClient={authClient} client={convexClient}>
      {content}
    </ConvexBetterAuthProvider>
  );
}

Protected Route Pattern

All dashboard pages enforce authentication at the layout level:

apps/dashboard/app/(dashboard)/layout.tsx
import { serverEnv } from "@workspace/env/server";
import { redirect } from "next/navigation";
import type * as React from "react";
import { Sidebar } from "~/components/dashboard/sidebar";
import { getCurrentSession } from "~/lib/auth/server";

export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";

export default async function DashboardRootLayout({
  children,
}: React.PropsWithChildren): Promise<React.JSX.Element> {
  const session = await getCurrentSession();

  // Redirect unauthenticated users to sign-in
  if (!session) {
    redirect("/sign-in");
  }

  return (
    <div className="flex min-h-screen bg-[#f7f8fa] text-slate-900">
      <Sidebar
        marketingUrl={serverEnv.APP_BASE_URL}
        user={{
          id: session.user.id,
          name: session.user.name,
          email: session.user.email,
          image: session.user.image || undefined,
        }}
      />
      <div className="flex flex-1 flex-col" style={{ marginLeft: "var(--sidebar-width, 256px)" }}>
        <main className="flex-1 overflow-y-auto px-8 py-12">
          <div className="mx-auto w-full max-w-5xl space-y-8">{children}</div>
        </main>
      </div>
    </div>
  );
}

Cache directives

revalidate = 0, dynamic = 'force-dynamic', and fetchCache = 'force-no-store' prevent Next.js from caching authenticated pages. Every request runs fresh auth checks.

Authentication Flow

BetterAuth Handler

The dashboard hosts the BetterAuth API handler at /api/auth/[...all]:

import { nextJsHandler } from "@convex-dev/better-auth/nextjs";
import { loadServerEnv } from "@workspace/env/server";
import { secureErrorJson } from "@workspace/security";
import type { NextRequest } from "next/server";

export const runtime = "nodejs";
export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";

let cachedHandlers: ReturnType<typeof nextJsHandler> | null = null;

function getConfiguredDashboardOrigin(): string | null {
  const { DASHBOARD_BASE_URL } = loadServerEnv();
  return DASHBOARD_BASE_URL ? DASHBOARD_BASE_URL.replace(/\/$/, "") : null;
}

async function getAuthHandlers() {
  if (cachedHandlers) return cachedHandlers;
  const { CONVEX_SITE_URL } = loadServerEnv();
  cachedHandlers = CONVEX_SITE_URL
    ? nextJsHandler({ convexSiteUrl: CONVEX_SITE_URL })
    : nextJsHandler();
  return cachedHandlers;
}

function assertSameOrigin(request: NextRequest) {
  const method = request.method.toUpperCase();
  const isMutation = ["POST", "PUT", "PATCH", "DELETE"].includes(method);
  if (!isMutation) return;

  const pathname = new URL(request.url).pathname;
  if (pathname.startsWith("/api/auth/callback")) return; // Allow OAuth callbacks

  const origin = request.headers.get("origin");
  const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
  const requestOrigin = new URL(request.url).origin;
  const configuredOrigin = getConfiguredDashboardOrigin();
  const allowedOrigins = new Set<string>([requestOrigin]);
  if (configuredOrigin) allowedOrigins.add(configuredOrigin);

  const originAllowed = origin ? allowedOrigins.has(origin) : false;
  if (origin && !originAllowed) {
    throw new Error("Forbidden: same-origin requests only");
  }

  if (fetchSite !== "same-origin") {
    throw new Error("Forbidden: same-origin requests only");
  }
}

export async function POST(req: NextRequest) {
  try {
    assertSameOrigin(req); // CSRF protection
  } catch (error) {
    return secureErrorJson(
      { message: (error as Error).message, code: "CSRF_PROTECTION" },
      { status: 403 }
    );
  }

  try {
    const { POST: handler } = await getAuthHandlers();
    const response = await handler(req);
    return hardenAuthResponse(response);
  } catch (error) {
    const message =
      process.env.NODE_ENV === "production"
        ? "Authentication service temporarily unavailable"
        : ((error as Error)?.message ?? "Authentication error");
    return secureErrorJson(
      { message, code: "AUTH_SERVICE_ERROR" },
      { status: 503, userScoped: true }
    );
  }
}

Pro Tip

The assertSameOrigin function enforces CSRF protection by checking the Origin and Sec-Fetch-Site headers. Requests from marketing or other origins receive a 403.

Middleware and Security

Dashboard middleware generates dynamic CSP with per-request nonces:

apps/dashboard/middleware.ts
import { isStaticAssetPath } from "@workspace/security";
import { type NextRequest, NextResponse } from "next/server";
import { buildDynamicCsp, extractReportUri, REPORTING_GROUP } from "~/lib/csp";

const isProd = process.env.NODE_ENV === "production";
const enableHsts = process.env.ENABLE_HSTS === "1";

function createNonce(): string {
  const bytes = new Uint8Array(16);
  crypto.getRandomValues(bytes);
  const binary = String.fromCharCode(...bytes);
  return btoa(binary)
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

export default function middleware(request: NextRequest) {
  const nonce = createNonce();
  const reportUri = extractReportUri(request);
  const csp = buildDynamicCsp({ nonce, reportUri, isProd });

  const reqHeaders = new Headers(request.headers);
  reqHeaders.set("x-nonce", nonce);

  const response = NextResponse.next({ request: { headers: reqHeaders } });
  const headerName = isProd
    ? "Content-Security-Policy"
    : "Content-Security-Policy-Report-Only";
  response.headers.set(headerName, csp);

  if (!isStaticAssetPath(request.nextUrl.pathname)) {
    response.headers.set("Cache-Control", "no-store"); // Never cache authenticated HTML
  }

  response.headers.set("x-nonce", nonce);
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
  response.headers.set("X-Content-Type-Options", "nosniff");
  response.headers.set("Permissions-Policy", "camera=(), microphone=()");

  return response;
}

Nonce-based CSP

The nonce prevents inline script attacks. Server components can read the nonce from headers (x-nonce) and pass it to client components if needed.

Routing Examples

Main App Page

The authenticated home page (/dashboard):

apps/dashboard/app/(dashboard)/page.tsx
import { Card, CardContent } from "@workspace/ui/components/card";
import { Sparkles } from "lucide-react";
import { redirect } from "next/navigation";
import { getCurrentSession } from "~/lib/auth/server";
import { createPageMetadata } from "~/lib/metadata";

export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";

export const metadata = createPageMetadata("App");

export default async function AppHome(): Promise<React.JSX.Element> {
  const session = await getCurrentSession();

  if (!session?.user?.id) {
    redirect("/sign-in");
  }

  return (
    <div className="space-y-10">
      <header className="space-y-2">
        <h1 className="font-semibold text-3xl text-slate-900 tracking-tight">
          App workspace
        </h1>
        <p className="text-slate-600 text-sm">
          This page is intentionally minimal. Use it as the canvas for your product experience.
        </p>
      </header>

      <Card className="border-slate-200 bg-white shadow-sm">
        <CardContent className="flex flex-col items-center gap-6 py-12 text-center">
          <span className="flex h-16 w-16 items-center justify-center rounded-xl bg-slate-100">
            <Sparkles className="h-7 w-7 text-slate-600" />
          </span>
          <div className="space-y-3">
            <h2 className="font-semibold text-2xl text-slate-900">
              Welcome to your app
            </h2>
            <p className="mx-auto max-w-xl text-slate-600 text-sm">
              Replace this placeholder with the flows, dashboards, or content your customers
              should see first. Cards, tables, and rich text components from the design system
              are available to help you move quickly.
            </p>
          </div>
        </CardContent>
      </Card>
    </div>
  );
}

Billing Page

apps/dashboard/app/(dashboard)/billing/page.tsx
import { BillingDashboard } from "~/components/billing/billing-dashboard";
import { createPageMetadata } from "~/lib/metadata";

export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";

export const metadata = createPageMetadata("Billing");

export default function BillingPage(): React.JSX.Element {
  return (
    <div className="space-y-8">
      <header className="space-y-2">
        <h1 className="font-semibold text-3xl text-slate-900 tracking-tight">
          Billing & subscriptions
        </h1>
        <p className="max-w-2xl text-slate-600 text-sm">
          Review your current plan, usage, and invoices. Use this page to keep billing details
          tidy for every workspace.
        </p>
      </header>
      <BillingDashboard />
    </div>
  );
}

API Routes

Support Tickets

Protected API route with rate limiting and CSRF protection:

apps/dashboard/app/api/support/route.ts
import { api } from "@convex/_generated/api";
import { getToken } from "@convex-dev/better-auth/nextjs";
import { secureErrorJson, secureUserJson } from "@workspace/security";
import { fetchMutation } from "convex/nextjs";
import type { NextRequest } from "next/server";
import { z } from "zod";
import { getCurrentSession } from "~/lib/auth/server";
import { enforceMutationRequest } from "~/lib/security/csrf";

export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const runtime = "nodejs";

const createSupportTicketSchema = z.object({
  title: z.string().trim().min(1, "Title is required").max(200, "Title too long"),
  description: z.string().trim().min(1, "Description is required").max(5000, "Description too long"),
});

async function requireSession() {
  const session = await getCurrentSession();
  if (!session) {
    throw new Error("Authentication required");
  }
  return session;
}

async function parseRequestBody(req: NextRequest) {
  const payload = await req.json();
  const parsed = createSupportTicketSchema.safeParse(payload);
  if (!parsed.success) {
    throw new Error("Validation failed");
  }
  return parsed.data;
}

export async function POST(req: NextRequest) {
  try {
    // CSRF protection
    try {
      enforceMutationRequest(req);
    } catch {
      throw new Error("Forbidden: Invalid request origin");
    }

    await requireSession();
    const body = await parseRequestBody(req);
    const token = await getToken(/* auth instance */);

    // Convex mutation with rate limiting
    const ticketId = await fetchMutation(
      api.support.createSupportTicket,
      body,
      { token }
    );

    return secureUserJson(
      { success: true, ticketId, message: "Support ticket created successfully" },
      { status: 201 }
    );
  } catch (error) {
    return secureErrorJson(
      { message: (error as Error).message, code: "SERVER_ERROR" },
      { status: 500 }
    );
  }
}

Rate limiting in Convex

The createSupportTicket mutation in Convex applies durable rate limiting (10 requests/min per user). The dashboard route doesn't need additional rate limiting logic.

Security Posture

Cookies

__Host-session cookie with Secure, HttpOnly, SameSite=Lax, Path=/. No Domain attribute—sessions are scoped to dashboard origin only.

Same-Origin Enforcement

POST/PUT/PATCH/DELETE require Origin === DASHBOARD_BASE_URL and Sec-Fetch-Site === 'same-origin'. Requests from marketing return 403.

CSP Nonces

Middleware generates per-request nonces for script-src 'nonce-...' 'strict-dynamic'. Inline scripts without nonces are blocked.

No CORS

Credentialed CORS is disabled. APIs never send Access-Control-Allow-Credentials to other origins.

Environment Variables

VariableDescriptionExample
DASHBOARD_BASE_URLDashboard originhttp://localhost:3001
APP_BASE_URLMarketing originhttp://localhost:3000
BETTER_AUTH_SECRETBetterAuth encryption keyyour-secret-key
GOOGLE_CLIENT_IDOAuth client ID123456.apps.googleusercontent.com
GOOGLE_CLIENT_SECRETOAuth client secretGOCSPX-...
CONVEX_URLConvex deployment URLhttps://abc.convex.cloud
NEXT_PUBLIC_CONVEX_URLPublic Convex URLSame as CONVEX_URL
AUTUMN_SECRET_KEYUseAutumn billing keyam_sk_dev_...

Commands

Dashboard app commands
# Development
pnpm --filter dashboard dev        # Start dev server (port 3001)
pnpm --filter dashboard build      # Production build
pnpm --filter dashboard typecheck  # Type checking
pnpm --filter dashboard lint       # Linting
pnpm --filter dashboard test       # Run tests

Testing

Verify auth redirects, billing, and CSP nonces:

Test commands
# Unit tests
pnpm test -- --run apps/dashboard/__tests__

# Integration tests
pnpm test -- --run tests/integration/dashboard-app-routing.test.ts

# Full validation
pnpm validate

Common Issues

Rollback Plan

If auth deployment fails:

  1. Keep marketing online: Marketing is stateless and doesn't depend on dashboard
  2. Point CTAs to maintenance page: Update /sign-in links to show maintenance message
  3. Fix and redeploy dashboard: Because cookies are scoped to dashboard host, marketing cannot impersonate users
  4. Restore links: After dashboard is fixed, restore /sign-in CTAs