Freeframe

Documentation

Launch

v1.1.6Updated Oct 17, 2026A polished SaaS product website with pricing, sign-in and dark mode.

Overview#

Launch is a complete marketing website for a software product, built for the fictional product-planning tool "Launch": a hero with a product screenshot built in HTML, feature deep-dives, a pricing page with a monthly/annual switch and comparison table, an about page, a validated contact form and a sign-in / create-account page, in light and dark themes. This is the Next.js 16 (App Router) + React 19 + TypeScript + Tailwind CSS v4 version, version 1.1.4. It exports to static files and can be hosted anywhere. See it live at freeframe.dev/templates/launch.

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install
pnpm dev          # http://localhost:3000

With npm: npm install, then npm run dev.

Command What it does
pnpm dev Development server at http://localhost:3000
pnpm build Static export to out/
pnpm start Serve out/ locally (npx serve@latest out)
pnpm lint ESLint (Next.js core-web-vitals and TypeScript rules)
pnpm typecheck Generate route types, then type-check with tsc (TypeScript 7)
pnpm build:demo Build for /demos/nextjs/launch into demo/ (only used for the demo on freeframe.dev)

Pages#

Route File What's on it
/ src/app/page.tsx Hero with product screenshot built in HTML, logo cloud, features, keyboard section, count-up statistics, testimonials, pricing teaser, call to action
/features src/app/features/page.tsx Roadmaps, issues, releases, feedback and insights sections with HTML/SVG illustrations, integrations grid
/pricing src/app/pricing/page.tsx Plans with a monthly/annual switch, enterprise banner, comparison table, FAQ accordion
/about src/app/about/page.tsx Story and timeline, values, team, open roles
/contact src/app/contact/page.tsx Validated contact form, support options, office addresses
/login src/app/login/page.tsx Sign in / create account tabs with validation (demo only)
404 src/app/not-found.tsx Not-found page

Project structure#

launch/
├─ src/
│  ├─ app/
│  │  ├─ layout.tsx          root layout: font, metadata, theme script, header, footer, back to top
│  │  ├─ globals.css         Tailwind theme (brand colours, font) and shared component classes
│  │  ├─ icon.svg            favicon
│  │  ├─ page.tsx            home page
│  │  ├─ <route>/page.tsx    one folder per page (about, contact, features, login, pricing)
│  │  └─ not-found.tsx       404 page
│  ├─ components/
│  │  ├─ site-header.tsx     header, desktop and mobile navigation
│  │  ├─ site-footer.tsx     footer links and copyright
│  │  ├─ theme-toggle.tsx    light/dark switch
│  │  ├─ billing.tsx         monthly/annual pricing switch, prices and notes
│  │  ├─ accordion.tsx       FAQ accordion
│  │  ├─ tabs.tsx            accessible tabs (sign in / create account)
│  │  ├─ forms.tsx           form validation, success states, password toggle
│  │  ├─ count-up.tsx        animated statistics
│  │  ├─ reveal-observer.tsx fade-in on scroll
│  │  ├─ back-to-top.tsx     floating "Back to top" button
│  │  ├─ icon.tsx            icon set (<Icon name="…" />)
│  │  └─ logo.tsx            logo mark and wordmark
│  ├─ data/
│  │  ├─ nav.ts              header navigation links
│  │  └─ pricing.ts          plans, comparison table and FAQ
│  ├─ lib/
│  │  ├─ site.ts             site name, URL and social image
│  │  └─ ui-store.ts         tiny shared store for UI state (tabs, billing, forms)
│  └─ fonts/                 Inter (self-hosted) and its OFL license
├─ public/og-image.png       1200×630 social sharing image
├─ next.config.ts            static export and optional base path
├─ eslint.config.mjs, postcss.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md

Everything is a React Server Component except the small interactive pieces, which start with "use client".

Customising#

Brand and colours#

All colours come from the @theme block at the top of src/app/globals.css. Buttons, links, accents, focus rings and the back-to-top hover state use the brand-* scale:

css
@theme {
  --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;

  --color-brand-500: #7451fa;
  --color-brand-600: #6233ee;
  --color-brand-700: #5223d4;
  /* ...50 to 950 */
}

Replace all eleven --color-brand-* values (50 to 950) with a scale for your colour. Greys come from Tailwind's built-in zinc palette. The logo mark in src/components/logo.tsx has its own gradient colours; update those too. The browser theme colour is the viewport.themeColor export in src/app/layout.tsx.

Shared component classes (.btn, .btn-primary, .btn-secondary, .btn-dark, .btn-lg, .eyebrow, .text-gradient, .card, .label, .input, .field-error) are defined in src/app/globals.css.

Typography#

Inter is self-hosted from src/fonts/Inter-latin.woff2 with next/font/local in src/app/layout.tsx (no requests to Google), exposed as the --font-inter variable. To use another font, either replace the file and the localFont() call, or switch to next/font/google:

tsx
import { Manrope } from "next/font/google";

const manrope = Manrope({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
// ...then use manrope.variable in the <html> className

Keeping the variable name --font-inter means globals.css doesn't need to change.

Content#

  • Page text lives in each src/app/<route>/page.tsx. Page titles and descriptions are in the metadata export at the top of each file.

  • Pricing lives in src/data/pricing.ts: plans (name, description, price.monthly / price.annual, notes, call to action, featured, feature list), comparison (groups of rows with a value per plan: true, false or text) and faqs. Add a plan:

    ts
    {
      name: "Scale",
      description: "For organisations with many teams.",
      price: { monthly: "39", annual: "32" },
      note: { monthly: "billed monthly", annual: "billed annually ($384/member/year)" },
      cta: { label: "Contact sales", href: "/contact?topic=sales", primary: false },
      featured: false,
      features: ["Everything in Business", "Dedicated success manager"],
    },

    If you add a plan, add a matching value to every comparison row and widen the pricing grid in src/app/pricing/page.tsx.

  • Statistics on the home page use <CountUp value={4000} suffix="+" /> (decimals for decimals).

  • Contact topics are the <option> values of the topic field in src/app/contact/page.tsx (sales, support, billing, partnerships, press, careers); /contact?topic=sales preselects one through the field's prefillParam="topic".

  • Icons: src/components/icon.tsx holds the Heroicons the site uses. Add one by pasting its path data into ICONS, then use <Icon name="your-icon" />.

Images and artwork#

There are no photos: the product screenshot, charts and avatars are built from JSX, Tailwind classes and inline SVG. To use real images, put them in public/ and use an <img> (or next/image; images are unoptimised for the static export):

tsx
<img src={`${process.env.NEXT_PUBLIC_BASE_PATH}/screenshot.png`} alt="The Launch roadmap view"
     width={1600} height={1000} className="rounded-2xl ring-1 ring-zinc-200" />

Prefixing with NEXT_PUBLIC_BASE_PATH keeps paths working when the site is served from a sub-folder. Keep screenshots around 16:10. The favicon is src/app/icon.svg; the social image is public/og-image.png (1200×630).

  • Header links come from mainNav in src/data/nav.ts; both the desktop and mobile menus use it.
  • The header itself (logo, theme toggle, sign-in buttons, mobile menu) is src/components/site-header.tsx.
  • The footer is src/components/site-footer.tsx.

To add a page, create src/app/<route>/page.tsx with a metadata export, then add it to mainNav and the footer.

Dark mode#

src/app/layout.tsx contains a small themeScript that runs before the page paints: it adds the dark class to <html> from the saved choice or, if there isn't one, the system setting. The ThemeToggle component (src/components/theme-toggle.tsx) flips the class and saves the choice in localStorage under theme.

  • Default to light: in themeScript, remove ||(!t&&window.matchMedia(...).matches).
  • Disable dark mode: remove <ThemeToggle /> from the header, the dark logic from themeScript, and the @custom-variant dark line from globals.css.

Features#

Mobile menu#

src/components/site-header.tsx renders a menu button below the md breakpoint that opens the mobile navigation. It updates aria-expanded and its label, closes on Escape (returning focus to the button) and closes when you choose a link.

Scroll reveal#

RevealObserver (src/components/reveal-observer.tsx, mounted once in the layout) fades in every .reveal element as it scrolls into view and re-runs on each route change. Elements above one that becomes visible are revealed too, so jumping with anchors or the End key never leaves hidden content. reveal-delay-1 to reveal-delay-3 stagger them. The styles live in globals.css; with reduced motion everything shows immediately.

Pricing switch#

src/components/billing.tsx exports BillingSwitch (a role="switch" button), BillingLabel, BillingPrice and BillingNote. They share one value in the UI store under the key billing-annual, so every price and note on the page changes together. It starts on monthly.

FAQ accordion#

AccordionButton and AccordionPanel (src/components/accordion.tsx) render each FAQ entry from faqs. The button sets aria-expanded; closed panels are inert.

Sign-in tabs#

TabList, Tab and TabPanel (src/components/tabs.tsx) implement accessible tabs: click or use the Left/Right arrow keys. A URL hash naming a tab opens it, so /login#signup (used by "Start free") opens "Create account".

Count-up statistics#

CountUp counts from zero over 1.4 seconds when the number scrolls into view. The final value is rendered on the server, so it's correct without JavaScript and with reduced motion.

Back to top button#

BackToTop (src/components/back-to-top.tsx) is mounted once in src/app/layout.tsx. It appears after scrolling down one screen, scrolls back up (instantly with reduced motion), moves keyboard focus to #main, can't be focused while hidden (inert) and is hidden when printing. Restyle it with the classes on its <button>, or remove <BackToTop /> from the layout.

Forms#

src/components/forms.tsx provides validation built on the browser's Constraint Validation API:

tsx
<ValidatedForm id="contact-form" className="space-y-6">
  <Field as="input" id="email" formId="contact-form" name="email" type="email" required className="input" />
  <FieldError formId="contact-form" fieldId="email" />
  <button type="submit" className="btn btn-primary">Send</button>
</ValidatedForm>
<FormSuccess formId="contact-form" id="contact-success">…</FormSuccess>

On submit every field is checked, errors appear under each field (aria-invalid is set) and focus moves to the first problem. After a first submit, fields re-check as you type. A valid form hides itself and shows its FormSuccess; FormResetButton brings the form back. PasswordToggle shows or hides a password. Extra options on a field: data-message (custom error text) and data-match / data-match-message (must equal another field, e.g. "confirm password").

Form Page Fields and rules
contact-form /contact Name, email (valid), company (optional), team size, topic, message (20+ characters), consent checkbox
signin-form /login Email, password (8+ characters), "Keep me signed in"
signup-form /login Name, email, password (8+ characters), terms checkbox

The demo forms don't send anything. The handleValid function inside ValidatedForm only waits 500 ms and shows the success state. Connect it in one of two ways.

A form service (for example Formspree), keeping the static export:

tsx
async function handleValid() {
  const form = ref.current!;
  const res = await fetch("https://formspree.io/f/your-id", {
    method: "POST",
    body: new FormData(form),
    headers: { Accept: "application/json" },
  });
  if (res.ok) setUiState(formKey(id), { ...getUiState(formKey(id), EMPTY), sent: true });
}

Every field has a name, so FormData includes them all. If you have several forms with different endpoints, add an action prop to ValidatedForm and read it in handleValid.

Your own server code: remove output: "export" from next.config.ts (see Building and deploying) and add a Route Handler, e.g. src/app/api/contact/route.ts:

ts
export async function POST(request: Request) {
  const data = await request.formData();
  // validate, then send an email or save to a database
  return Response.json({ ok: true });
}

and post to /api/contact from handleValid. The sign-in and sign-up forms need a real authentication provider; treat them as layouts to connect.

Saved data (localStorage)#

Key Value Used for
theme "dark" or "light" The visitor's colour theme choice

Any other value is ignored and the system setting is used. Other UI state (tabs, billing, forms) lives in memory in src/lib/ui-store.ts and resets when the visitor leaves the page.

SEO and sharing#

  • Site-wide defaults are the metadata export in src/app/layout.tsx; each page overrides title, description and openGraph in its own metadata export.

  • metadataBase is site.url from src/lib/site.ts, which reads NEXT_PUBLIC_SITE_URL (default https://launch.example.com). Set it to your domain when you build, so the social image URL is absolute:

    sh
    NEXT_PUBLIC_SITE_URL=https://example.com pnpm build
  • The social image is site.ogImage (public/og-image.png, 1200×630). Replace the file to change it.

  • Add a sitemap and robots file with the App Router's src/app/sitemap.ts and src/app/robots.ts (both work with static export).

Accessibility#

Built in:

  • A "Skip to content" link to #main and landmarks (header, nav, main, footer).
  • Visible focus styles and keyboard support for the menu, tabs, accordion, billing switch, password toggle and the sideways-scrolling comparison table.
  • Correct ARIA: aria-expanded, role="tab" / aria-selected, role="switch" / aria-checked, aria-pressed, aria-invalid with error messages linked through aria-describedby.
  • Reduced motion: reveals, count-ups, smooth scrolling and the back-to-top animation are turned off.
  • WCAG AA contrast in light and dark themes.

When editing, keep one <h1> per page, keep heading levels in order, give every new field a <label> and a FieldError, and re-check contrast if you change colours.

Building and deploying#

sh
pnpm build        # static site in out/
pnpm start        # preview out/ locally

next.config.ts sets output: "export" and images.unoptimized, so out/ is plain HTML, CSS and JavaScript. To serve the site from a sub-folder, set DEMO_BASE_PATH when building (it becomes basePath), e.g. DEMO_BASE_PATH=/launch pnpm build.

  • Vercel: import the repository; no settings needed.
  • Netlify / Cloudflare Pages: build command pnpm build, publish directory out.
  • Any static host: upload the contents of out/.

Need server features (Route Handlers, Server Actions, middleware)? Remove output: "export" and images: { unoptimized: true } from next.config.ts and deploy to a host that runs Next.js, such as Vercel.

demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.

TypeScript 7 setup#

package.json installs two TypeScript packages on purpose: @typescript/native (TypeScript 7, which provides tsc for pnpm typecheck) and typescript aliased to @typescript/typescript6 (the TypeScript 6 API that typescript-eslint and parts of the Next.js toolchain load). When typescript-eslint supports TypeScript 7, replace both with a single typescript@7. eslint.config.mjs sets settings.react.version because eslint-plugin-react can't detect React under ESLint 10.

Troubleshooting#

  • pnpm typecheck complains about missing route types. It runs next typegen first; make sure you use the script rather than calling tsc directly.
  • ESLint crashes with getFilename is not a function. Keep settings.react.version in eslint.config.mjs.
  • Images or links break when served from a sub-folder. Build with DEMO_BASE_PATH, and prefix hand-written asset URLs with process.env.NEXT_PUBLIC_BASE_PATH.
  • Social previews have no image. Set NEXT_PUBLIC_SITE_URL to your domain at build time.
  • The theme flashes on load. Keep the themeScript <script> in the layout's <head>.
  • Content stays invisible. .reveal elements are revealed by RevealObserver; make sure it's still rendered in src/app/layout.tsx.
  • next build fails after adding an API route. Route Handlers that read the request can't be statically exported; remove output: "export" (see above).

License#

MIT. The code and all included artwork are free for personal and commercial use; the download includes the license text in LICENSE.txt. Inter is licensed under the SIL Open Font License (src/fonts/OFL.txt).

Search documentation

Search the guides and every template's documentation