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.cloud
Start the 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:
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 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:
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
):
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
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:
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
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 | am_sk_dev_... |
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:
# 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:
- Keep marketing online: Marketing is stateless and doesn't depend on dashboard
- Point CTAs to maintenance page: Update
/sign-in
links 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-in
CTAs