StarterApp Docs
Applications

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.

Development

Install dependencies

From the monorepo root:

Install dependencies
pnpm install

Configure environment

Copy .env.example to .env.local and populate:

Environment configuration
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

Start marketing 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:

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

Root Layout

The root layout (apps/marketing/app/layout.tsx) defines global metadata and wraps the app with providers:

apps/marketing/app/layout.tsx
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):

apps/marketing/app/providers.tsx
"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:

apps/marketing/middleware.ts
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:

apps/marketing/app/(marketing)/page.tsx
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:

apps/marketing/app/(marketing)/pricing/page.tsx
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:

apps/marketing/app/(marketing)/layout.tsx
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:

apps/marketing/app/api/health/route.ts
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:

apps/marketing/app/api/csp-report/route.ts
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

VariableDescriptionExample
APP_BASE_URLMarketing originhttp://localhost:3000
DASHBOARD_BASE_URLDashboard originhttp://localhost:3001
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

Pro Tip

After updating .env.local, push secrets to Convex:

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

Marketing app 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

Common Issues