Launch NowDocs

Legal pages

Generate Terms, Privacy, Cookie, Refund, DPA and Legal Notice pages from one config file, check for missing values, and manage cookie consent.

Launch Now renders six legal documents from a single config file, lib/config/legal.ts. You fill in your company details once, and every page (Terms of Service, Privacy Policy, Cookie Policy, Refund Policy, Data Processing Agreement and Legal Notice) reads from it. A pnpm legal:check script lists the values you still need to provide, and a Claude Code skill can fill most of them for you by inspecting the code. A cookie consent banner, driven by the same config, ships alongside.

The documents are a starting point, not legal advice. Have a lawyer review them before launch, especially if you handle health, financial or children's data.

How it works

FileRole
lib/config/legal.tslegalConfig: company, contacts, host, jurisdiction, billing, sub-processors, cookies. Also findLegalPlaceholders().
features/legal/documents/*.tsOne builder per document. Each takes the config and returns sections.
features/legal/documents/index.tsRegisters the builders, in display order, as legalDocuments.
features/legal/types.tsDocument types and the section, p, list, table helpers.
app/legal/page.tsxThe /legal index.
app/legal/[document]/page.tsxOne statically generated page per document.
components/layouts/legal-page.tsxLayout: title, last-updated date, "On this page" nav, dev-only warning.
scripts/legal-check.tspnpm legal:check.
.claude/skills/legal-docs/The legal-docs skill for Claude Code.
features/cookie-consent/cookie-consent.tsxConsent banner, settings dialog, useCookieConsent().
lib/cookie-consent.tsConsent cookie storage and hasConsent().

The documents

PageSlugURLBuilder
Terms of Serviceterms/legal/termsfeatures/legal/documents/terms.ts
Privacy Policyprivacy/legal/privacyfeatures/legal/documents/privacy.ts
Cookie Policycookies/legal/cookiesfeatures/legal/documents/cookies.ts
Refund Policyrefund/legal/refundfeatures/legal/documents/refund.ts
Data Processing Agreementdpa/legal/dpafeatures/legal/documents/dpa.ts
Legal Noticelegal-notice/legal/legal-noticefeatures/legal/documents/legal-notice.ts

app/legal/[document]/page.tsx sets dynamicParams = false and generates one page per registered document, so an unknown slug returns a 404. Each page gets its title and description as metadata through createSeoHead() with a canonical URL, and every document is listed in app/sitemap.ts. All pages use the marketing shell.

The footer links to Terms, Privacy, Cookies and Legal notice. The full list is always available at /legal.

Fill in the config

Every value that still needs your input starts with TODO:

lib/config/legal.ts
export const legalConfig = {
  /** ISO date the current version of the documents takes effect. */
  effectiveDate: "2026-09-23",
 
  product: {
    name: siteConfig.name,
    url: siteConfig.url,
    /** One sentence: what the service does, used in the Terms. */
    description: "TODO: one-sentence description of what your SaaS does",
  },
 
  company: {
    legalName:
      "TODO: registered company name (or your full name if sole trader)",
    legalForm: "TODO: legal form",
    // ...
  },
  // ...
}

The main groups:

GroupWhat to set
effectiveDateThe date the current version takes effect. Shown as "Last updated" on every page. Bump it whenever the substance changes.
productName and URL default to siteConfig. Write the one-sentence description used in the Terms.
companyLegal name, legal form, registration number and registry, VAT number, share capital, address, country. Leave optional fields as "" when they don't apply.
contactemail and privacyEmail read APP_CONTACT_EMAIL; supportEmail reads APP_HELP_EMAIL. dpoEmail and phone are optional.
publicationDirectorRequired in France for the Legal Notice. Usually the CEO.
hostYour hosting provider's name, address and URL.
jurisdictionGoverning law, competent courts, and the data protection authority users can complain to.
audiencecustomers ("business-only" or "business-and-consumers"), minimumAge, applyGdpr, applyCcpa.
billingpaymentProvider, currency, trialDays, refundWindowDays (0 for no refunds).
retentionDays kept in backups (backupDays) and logs (logsDays) after account deletion.
subprocessorsEvery third party that processes personal data: name, purpose, location, url.
cookiesEvery cookie the app sets: name, provider, purpose, duration, category.

Set the two contact addresses in your environment:

.env
APP_CONTACT_EMAIL=contact@example.com
APP_HELP_EMAIL=help@example.com

Both are optional in lib/env.ts. When they are missing, the contact fields fall back to TODO values and pnpm legal:check flags them.

What the config changes in the documents

The builders adapt the text to the config instead of shipping every clause:

  • audience.customers: "business-and-consumers" adds the EU/UK 14-day right of withdrawal to the Terms and the Refund Policy. "business-only" removes it.
  • audience.applyGdpr and audience.applyCcpa toggle the GDPR and California sections of the Privacy Policy.
  • billing.trialDays > 0 adds a free trial section to the Refund Policy and changes the trial clause in the Terms.
  • billing.refundWindowDays sets the full-refund window for a first payment, or states that payments are non-refundable when it is 0.
  • contact.dpoEmail, when set, is used as the privacy contact in the Privacy Policy and the DPA.
  • subprocessors becomes a table in the Privacy Policy and the DPA; cookies becomes the table in the Cookie Policy.

The Legal Notice skips lines for empty optional fields (legal form, share capital, registration, VAT number, phone).

Check for missing values

pnpm legal:check

The script loads your .env files the same way Next.js does, then prints every config path still set to a TODO value, for example:

 3 legal field(s) still need a value:
 
  - company.legalName
  - company.address
  - host.name
 
Fill lib/config/legal.ts, or ask Claude to "generate my legal docs".

It exits with code 1 while any placeholder remains, so you can add it to CI to block a release with incomplete legal pages. When everything is filled it prints ✓ lib/config/legal.ts is complete.

The same check runs on the pages: outside production, each legal page shows a banner listing the fields that still need a value. The banner is never rendered when NODE_ENV is production.

Generate the documents with Claude Code

The repository includes a legal-docs skill in .claude/skills/legal-docs/. In Claude Code, ask:

generate my legal docs

The skill:

  1. Runs node .claude/skills/legal-docs/scripts/detect-stack.mjs . to detect sub-processors (from package.json, env key names and URL hostnames), the Better Auth plugins in use, and the cookies the code sets. It never prints secret values.
  2. Asks, in one message, only for what it can't detect: company details, host, jurisdiction, who you sell to, refund window. The questions are in references/questionnaire.md, adapted to your country.
  3. Edits lib/config/legal.ts, and only touches the builders when your product genuinely differs from what they describe. references/requirements.md lists the mandatory content per document under GDPR, CCPA, ePrivacy, the French LCEN and EU consumer law.
  4. Verifies with pnpm legal:check and a type check.

It never invents a company name, registration number, address or person's name: anything you don't know yet stays TODO. Run the skill again after you add a third-party service (analytics, email, AI, storage) so the sub-processor and cookie lists stay accurate.

Add a document

Write a builder

Create a file in features/legal/documents/ that returns a LegalDocument. Use the helpers from features/legal/types.ts and read values from the config instead of hardcoding names. sections() drops any false entry, so conditional sections are written as cond && section(...):

features/legal/documents/refund.ts
import { list, p, section, sections, type LegalDocumentBuilder } from "../types"
 
export const refundPolicy: LegalDocumentBuilder = (c) => {
  const { refundWindowDays, trialDays, paymentProvider } = c.billing
 
  return {
    slug: "refund",
    title: "Refund Policy",
    description: `How cancellations and refunds work for ${c.product.name} subscriptions.`,
    sections: sections(
      section(
        "cancel",
        "Cancelling",
        p("You can cancel your subscription at any time ...")
      ),
      trialDays > 0 &&
        section(
          "trial",
          "Free trial",
          p(`Trials last ${trialDays} days. ...`)
        ),
      // ...
    ),
  }
}

A section's id becomes its anchor in the "On this page" navigation. Blocks are paragraphs (p), bullet lists (list, which skips empty strings) or tables (table(head, rows)).

Register it

Add the builder to the builders array. The order is the order on /legal:

features/legal/documents/index.ts
const builders: LegalDocumentBuilder[] = [
  termsOfService,
  privacyPolicy,
  cookiePolicy,
  refundPolicy,
  dataProcessingAgreement,
  legalNotice,
]

The page, its metadata and its sitemap entry are generated automatically. Add a footer link in features/marketing/footer.tsx if visitors need it often.

CookieConsentProvider wraps the whole app in lib/providers.tsx. On a visitor's first visit it shows a banner with Reject all, Accept all and Customize. Rejecting is as easy as accepting, and Customize opens a settings dialog with one switch per category.

Categories

CategoryConsentExample cookies in the default config
essentialAlways onbetter-auth.session_token, cookie_consent, open_app
preferencesOptionalsidebar_state
analyticsOptionalNone
marketingOptionalNone

The dialog lists the cookies of each category straight from legalConfig.cookies, so the banner, the dialog and the Cookie Policy always agree. A category with no cookies shows "Not used at the moment."

How the choice is stored

lib/cookie-consent.ts stores the choice as JSON in the first-party cookie_consent cookie for about 6 months (183 days), then the banner asks again:

lib/cookie-consent.ts
export const CONSENT_COOKIE = "cookie_consent"
export const CONSENT_VERSION = 1
export const CONSENT_CHANGE_EVENT = "cookie-consent-change"

A stored choice is ignored if its version differs from CONSENT_VERSION. Bump the version when you add an optional category or a new tracker, so every visitor is asked again.

When a visitor withdraws consent for a category, the provider also deletes the first-party cookies of that category listed in legalConfig.cookies.

Visitors can reopen the settings at any time from Cookie settings in the marketing footer (CookieSettingsButton) or from the account menu in the app (components/user-dropdown.tsx).

In a client component, read the consent from the hook:

components/analytics.tsx
"use client"
 
import { useCookieConsent } from "@/features/cookie-consent/cookie-consent"
 
export function Analytics() {
  const { consent } = useCookieConsent()
  if (!consent?.analytics) return null
  // ... render your analytics script
}

consent is null until the visitor has chosen (and before hydration), so nothing loads by default. Outside React, use hasConsent("analytics") from lib/cookie-consent.ts. Code that needs to react to changes can listen for the cookie-consent-change window event, which carries the new consent in event.detail.

When you add a tracker, also add its cookies to legalConfig.cookies with the right category, update effectiveDate, and bump CONSENT_VERSION.

Next steps