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).
pnpm install
pnpm dev # http://localhost:3000With 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.mdEverything 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:
@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:
import { Manrope } from "next/font/google";
const manrope = Manrope({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
// ...then use manrope.variable in the <html> classNameKeeping 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 themetadataexport 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,falseor text) andfaqs. 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
comparisonrow and widen the pricing grid insrc/app/pricing/page.tsx. -
Statistics on the home page use
<CountUp value={4000} suffix="+" />(decimalsfor decimals). -
Contact topics are the
<option>values of thetopicfield insrc/app/contact/page.tsx(sales,support,billing,partnerships,press,careers);/contact?topic=salespreselects one through the field'sprefillParam="topic". -
Icons:
src/components/icon.tsxholds the Heroicons the site uses. Add one by pasting its path data intoICONS, 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):
<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).
Navigation, header and footer#
- Header links come from
mainNavinsrc/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, thedarklogic fromthemeScript, and the@custom-variant darkline fromglobals.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:
<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:
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:
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
metadataexport insrc/app/layout.tsx; each page overridestitle,descriptionandopenGraphin its ownmetadataexport. -
metadataBaseissite.urlfromsrc/lib/site.ts, which readsNEXT_PUBLIC_SITE_URL(defaulthttps://launch.example.com). Set it to your domain when you build, so the social image URL is absolute:shNEXT_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.tsandsrc/app/robots.ts(both work with static export).
Accessibility#
Built in:
- A "Skip to content" link to
#mainand 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-invalidwith error messages linked througharia-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#
pnpm build # static site in out/
pnpm start # preview out/ locallynext.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 directoryout. - 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 typecheckcomplains about missing route types. It runsnext typegenfirst; make sure you use the script rather than callingtscdirectly.- ESLint crashes with
getFilename is not a function. Keepsettings.react.versionineslint.config.mjs. - Images or links break when served from a sub-folder. Build with
DEMO_BASE_PATH, and prefix hand-written asset URLs withprocess.env.NEXT_PUBLIC_BASE_PATH. - Social previews have no image. Set
NEXT_PUBLIC_SITE_URLto your domain at build time. - The theme flashes on load. Keep the
themeScript<script>in the layout's<head>. - Content stays invisible.
.revealelements are revealed byRevealObserver; make sure it's still rendered insrc/app/layout.tsx. next buildfails after adding an API route. Route Handlers that read the request can't be statically exported; removeoutput: "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).