Marketing App
Production-ready marketing surface with authentication, billing, and AI patterns built in
The marketing app serves unauthenticated traffic at http://localhost:3000
in development. It provides landing pages, pricing, contact forms, and sign-in flows—all backed by real middleware, security headers, and routing patterns.
Quick Start
Run pnpm --filter marketing dev
to start on port 3000
Architecture
Route groups, layouts, and middleware explained
Security
CSP, CSRF protection, and secure headers
Routing Examples
Real pages from the codebase
Development
Configure environment
Copy .env.example
to .env.local
and populate:
APP_BASE_URL=http://localhost:3000
DASHBOARD_BASE_URL=http://localhost:3001
BETTER_AUTH_SECRET=your-secret
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
Start the dev server
pnpm --filter marketing dev
Marketing runs on http://localhost:3000
Pro Tip
Run pnpm dev
from the root to start both marketing (3000) and dashboard (3001) together.
App Router Structure
The marketing app uses Next.js 15 App Router with route groups for clean organization:
Root Layout
The root layout (apps/marketing/app/layout.tsx
) defines global metadata and wraps the app with providers:
import "./globals.css";
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 { SkipToContent } from "~/components/a11y/skip-to-content";
import { brandDescription, brandMetaDescription, brandName } from "~/lib/brand";
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 AI_APP_NAME = brandName;
const AI_APP_DESCRIPTION = brandDescription;
const { APP_BASE_URL } = loadServerEnv();
export const metadata: Metadata = {
metadataBase: new URL(APP_BASE_URL || "http://localhost:3000"),
title: {
default: AI_APP_NAME,
template: `%s | ${AI_APP_NAME}`,
},
description: brandMetaDescription,
openGraph: {
type: "website",
locale: "en_US",
siteName: AI_APP_NAME,
title: AI_APP_NAME,
description: AI_APP_DESCRIPTION,
images: { url: "/opengraph-image", width: 1200, height: 630 },
},
robots: { index: true, follow: true },
};
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`}>
<SkipToContent />
<Providers>{children}</Providers>
</body>
</html>
);
}
Pro Tip
The suppressHydrationWarning
attribute prevents warnings from theme providers that manipulate the DOM before React hydrates.
Providers
Marketing surfaces are unauthenticated. Providers only include UI utilities (theme, tooltips, toasts):
"use client";
import { Toaster } from "@workspace/ui/components/sonner";
import { TooltipProvider } from "@workspace/ui/components/tooltip";
import { ThemeProvider } from "@workspace/ui/hooks/use-theme";
import type * as React from "react";
export function Providers({
children,
}: React.PropsWithChildren): React.JSX.Element {
return (
<ThemeProvider
attribute="class"
defaultTheme="light"
disableTransitionOnChange
enableSystem={false}
>
<TooltipProvider>
{children}
<Toaster />
</TooltipProvider>
</ThemeProvider>
);
}
No Auth Providers
Marketing does not wrap children with ConvexBetterAuthProvider
. Authentication lives exclusively in the dashboard app.
Middleware and Security
Marketing middleware (apps/marketing/middleware.ts
) applies static CSP and security headers to all responses:
import { type NextRequest, NextResponse } from "next/server";
import { buildStaticCsp, extractReportUri, REPORTING_GROUP } from "~/lib/csp";
const isProd = process.env.NODE_ENV === "production";
const enableHsts = process.env.ENABLE_HSTS === "1";
const dashboardBaseUrl = process.env.DASHBOARD_BASE_URL || "http://localhost:3001";
// Redirect /dashboard/* to the dashboard app
function redirectToDashboard(request: NextRequest): NextResponse {
const stripped = request.nextUrl.pathname.replace(/^\/dashboard/, "");
const normalizedPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
const target = new URL(normalizedPath || "/", dashboardBaseUrl);
target.search = request.nextUrl.search;
return NextResponse.redirect(target, 308);
}
export default function middleware(request: NextRequest) {
// Redirect dashboard routes to the dashboard app
if (request.nextUrl.pathname.startsWith("/dashboard")) {
return redirectToDashboard(request);
}
// Block service workers explicitly
if (
request.nextUrl.pathname === "/service-worker.js" ||
request.nextUrl.pathname === "/sw.js"
) {
return new NextResponse("Service workers are disabled", { status: 404 });
}
const reportUri = extractReportUri(request);
const reportEndpoint = new URL("/api/csp-report", request.nextUrl.origin);
const headerName = isProd
? "Content-Security-Policy"
: "Content-Security-Policy-Report-Only";
const response = NextResponse.next();
response.headers.set(headerName, buildStaticCsp({ reportUri, isProd }));
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=()");
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
return response;
}
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};
Pro Tip
The matcher excludes API routes, static assets, and prefetch requests. Middleware only runs on actual page loads.
Routing Patterns
Landing Page
The marketing home page is a server component that composes sections:
import { loadServerEnv } from "@workspace/env/server";
import type { Metadata } from "next";
import { HeroSection } from "~/components/sections/section-hero";
import { FeaturesSection } from "~/components/sections/section-features";
import { BenefitsSection } from "~/components/sections/section-benefits";
import { FAQSection } from "~/components/sections/section-faq";
import { CTASection } from "~/components/sections/section-cta";
import { brandDescription, brandMetaDescription } from "~/lib/brand";
export const revalidate = 600; // ISR: regenerate every 10 minutes
export async function generateMetadata(): Promise<Metadata> {
const { APP_BASE_URL } = loadServerEnv();
return {
title: "Home",
description: brandMetaDescription,
alternates: { canonical: APP_BASE_URL },
openGraph: {
title: "Home",
description: brandDescription,
url: APP_BASE_URL,
images: [{ url: "/opengraph-image", width: 1200, height: 630 }],
},
};
}
export default async function HomePage() {
return (
<>
<HeroSection />
<FeaturesSection />
<BenefitsSection />
<FAQSection />
<CTASection />
</>
);
}
ISR Pattern
revalidate = 600
enables Incremental Static Regeneration. Next.js regenerates the page every 10 minutes, balancing freshness with performance.
Pricing Page
The pricing page renders unauthenticated pricing tables:
import type { Metadata } from "next";
import { PricingTable } from "~/components/billing/pricing-table";
import { brandDescription, brandMetaDescription } from "~/lib/brand";
export const metadata: Metadata = {
title: "Pricing Plans",
description: brandMetaDescription,
openGraph: {
title: "Pricing Plans | StarterApp",
description: brandDescription,
type: "website",
},
};
export default function PricingPage() {
return <PricingTable />;
}
Marketing Layout
Pages inside (marketing)/
inherit this layout:
import type * as React from "react";
import { Footer } from "~/components/footer";
import { Navbar } from "~/components/navbar";
export default function MarketingLayout({
children,
}: React.PropsWithChildren): React.JSX.Element {
return (
<div className="flex min-h-screen flex-col">
<Navbar />
<main className="flex-1" id="main-content">
{children}
</main>
<Footer />
</div>
);
}
API Routes
Health Check
Simple endpoint for uptime monitoring:
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export async function GET() {
return NextResponse.json({ ok: true }, { status: 200 });
}
CSP Violation Reports
Collects Content Security Policy violations for monitoring:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const body = await req.json();
console.warn("CSP Violation:", body);
return new NextResponse(null, { status: 204 });
}
Heads up
In production, send CSP reports to a monitoring service (Sentry, LogRocket, etc.) instead of logging to console.
Environment Variables
Variable | Description | Example |
---|---|---|
APP_BASE_URL | Marketing origin | http://localhost:3000 |
DASHBOARD_BASE_URL | Dashboard origin | http://localhost:3001 |
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 |
Pro Tip
After updating .env.local
, push secrets to Convex:
npx convex env set AUTUMN_SECRET_KEY your-autumn-secret
npx convex env set ALLOWED_WEB_ORIGINS http://localhost:3000,http://localhost:3001
npx convex env set DASHBOARD_BASE_URL http://localhost:3001
npx convex env set APP_BASE_URL http://localhost:3000
npx convex env set GOOGLE_CLIENT_ID your-google-client-id
npx convex env set GOOGLE_CLIENT_SECRET your-google-client-secret
npx convex env set BETTER_AUTH_SECRET your-auth-secret
Commands
# Development
pnpm --filter marketing dev # Start dev server (port 3000)
pnpm --filter marketing build # Production build
pnpm --filter marketing typecheck # Type checking
pnpm --filter marketing lint # Linting
pnpm --filter marketing test # Run tests