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 user = await getCurrentSession();

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

  return (
    <div className="flex min-h-screen bg-[#f7f8fa] text-slate-900">
      <Sidebar
        marketingUrl={serverEnv.APP_BASE_URL}
        user={{
          id: user.id,
          name: user.name,
          email: user.email,
          image: 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 enforceDashboardCsrf(request: NextRequest) {
  const method = request.method.toUpperCase();
  const requested = request.headers
    .get("access-control-request-method")
    ?.toUpperCase();
  const effectiveMethod = method === "OPTIONS" && requested ? requested : method;

  if (!["POST", "PUT", "PATCH", "DELETE"].includes(effectiveMethod)) {
    return;
  }

  const pathname = new URL(request.url).pathname;
  if (pathname.startsWith("/api/auth/callback")) {
    return;
  }

  enforceMutationRequest(request);
}

export async function POST(req: NextRequest) {
  try {
    enforceDashboardCsrf(req);
    const { POST: handler } = await getAuthHandlers();
    return hardenAuthResponse(await handler(req));
  } catch (error) {
    return secureErrorJson(
      {
        message:
          process.env.NODE_ENV === "production"
            ? "Authentication request blocked"
            : (error as Error).message ?? "CSRF protection triggered",
        code: "CSRF_PROTECTION",
      },
      { status: (error as { status?: number }).status ?? 403, userScoped: true }
    );
  }
}

API Routes

StarterApp ships a minimal set of API routes:

  • api/auth/[...all] – BetterAuth proxy route mounted from Convex.
  • api/csp-report – CSP violation collector for both apps.

Use the auth-api-route-template.ts and billing-api-gating-template.ts from llms/templates/ when adding additional endpoints. Both templates include CSRF guards, secure JSON helpers, and rate limit guidance.

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 key (optional)am_sk_dev_...
DISABLE_USERNAME_PASSWORD_AUTHtrue to require Google-only sign infalse

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