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.
Quick Start
Run pnpm --filter dashboard dev to start on port 3001
Authentication
BetterAuth integration with session management
Protected Routes
Server-side auth checks and redirects
Security
CSP nonces, CSRF protection, same-origin enforcement
Development
Configure environment
Dashboard reads from @workspace/env. Key variables:
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.cloudStart the dev server
pnpm --filter dashboard devDashboard 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:
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:
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:
"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:
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
| Variable | Description | Example |
|---|---|---|
DASHBOARD_BASE_URL | Dashboard origin | http://localhost:3001 |
APP_BASE_URL | Marketing origin | http://localhost:3000 |
BETTER_AUTH_SECRET | BetterAuth encryption key | your-secret-key |
GOOGLE_CLIENT_ID | OAuth client ID | 123456.apps.googleusercontent.com |
GOOGLE_CLIENT_SECRET | OAuth client secret | GOCSPX-... |
CONVEX_URL | Convex deployment URL | https://abc.convex.cloud |
NEXT_PUBLIC_CONVEX_URL | Public Convex URL | Same as CONVEX_URL |
AUTUMN_SECRET_KEY | UseAutumn billing key (optional) | am_sk_dev_... |
DISABLE_USERNAME_PASSWORD_AUTH | true to require Google-only sign in | false |
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 testsTesting
Verify auth redirects, billing, and CSP nonces:
# Unit tests
pnpm test -- --run apps/dashboard/__tests__
# Integration tests
pnpm test -- --run tests/integration/dashboard-app-routing.test.ts
# Full validation
pnpm validateCommon Issues
Rollback Plan
If auth deployment fails:
- Keep marketing online: Marketing is stateless and doesn't depend on dashboard
- Point CTAs to maintenance page: Update
/sign-inlinks to show maintenance message - Fix and redeploy dashboard: Because cookies are scoped to dashboard host, marketing cannot impersonate users
- Restore links: After dashboard is fixed, restore
/sign-inCTAs