Freeframe

Documentation

Forge

v1.0.1Updated Oct 17, 2026A dark, terminal-inspired website for an API product, with a typed live code sample, an API explorer and usage-based pricing.

Overview#

Forge is a marketing and developer website for an API product, built for Forge, a fictional webhooks API made by Forge Systems B.V. (Rotterdam and Montréal). Developers send each event once; Forge signs it, fans it out to every subscribed endpoint, retries and keeps a replayable log. The site has a home page with a self-typing code sample and an interactive API explorer, a quickstart, an SDK directory, usage-based pricing with a calculator, a changelog, a status page, a company page, a sales form and an API key sign-up demo. This is the Next.js version (Next.js 16 App Router, React 19, TypeScript, Tailwind CSS v4), version 1.0.0, ported page for page from the HTML template. The design is terminal-inspired: near-black surfaces inside a framed hairline grid, IBM Plex Mono labels and a single amber accent. It exports to static files. Live demo: https://freeframe.dev/templates/forge

The site is a front end only. The code sample, the API explorer and the status data are simulated in the browser, and the forms don't send anything until you connect your own services (see Forms).

Quick start#

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

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

Other scripts:

Script What it does
pnpm build Static export to out/
pnpm start Serve out/ (npx serve out)
pnpm lint ESLint
pnpm typecheck next typegen and TypeScript 7 tsc --noEmit
pnpm build:demo Build for freeframe.dev under /demos/nextjs/forge into demo/

Pages#

Route File Purpose and features
/ src/app/page.tsx Typed code sample (#hero-code), headline numbers, customer wordmarks, feature grid (#features), how-it-works diagram, API explorer (#inspector), reliability stats with latency chart (#reliability), testimonials, call to action
/docs src/app/docs/page.tsx Quickstart: six steps with tabbed, copyable code (#install, #auth, #endpoint, #send, #verify, #test), "On this page" navigation, next steps
/sdks src/app/sdks/page.tsx 16 library cards with install commands, type filter (/sdks?type=tool), search, versioning policy (#versioning)
/pricing src/app/pricing/page.tsx Monthly / annual switch, four plans, volume calculator (#calculator), comparison table (#compare), FAQ accordion (#faq)
/changelog src/app/changelog/page.tsx Entries grouped by month with type tags, filter (/changelog?type=breaking)
/status src/app/status/page.tsx Overall status, scheduled maintenance, component-by-region grid, 90-day uptime bars with a region switch, incident history
/company src/app/company/page.tsx Story, figures, principles, team, offices, open roles (#careers)
/contact src/app/contact/page.tsx What happens next, direct contacts, sales form (/contact?volume=250m preselects the volume)
/signup src/app/signup/page.tsx Sign-up form that generates a demo key (/signup?plan=scale shows the plan)
404 src/app/not-found.tsx Terminal-style 404 response with the requested path and links back into the site (exported as 404.html, noindex)

Project structure#

src/
  app/
    layout.tsx            <html>, skip link, header, <main> with the frame, footer, live region, reveals, back to top
    globals.css           Tailwind import, @theme tokens, component classes (frame, buttons, tabs, code, uptime bars...)
    fonts.ts              Host Grotesk and IBM Plex Mono with next/font/local
    page.tsx              home page; one folder per other route with its page.tsx
    not-found.tsx         404 page
    icon.svg              favicon (the amber F mark)
    opengraph-image.png   social sharing image (1200×630)
  components/
    site-header.tsx       sticky header, main and mobile navigation (client)
    site-footer.tsx       footer with link columns, status pill and the newsletter form
    logo.tsx, nav-link.tsx, year.tsx
    hero-code.tsx         typed code sample on the home page (client)
    explorer.tsx          API explorer (client)
    latency-chart.tsx     SVG latency chart (client)
    lang-tabs.tsx         language tabs and tabbed code cards, synced and remembered (client)
    code-block.tsx        highlighted code blocks and terminal cards
    copy-button.tsx       copy-to-clipboard buttons (client)
    pricing.tsx           billing switch, plan cards and volume calculator (client)
    accordion.tsx         FAQ accordion (client)
    filter.tsx            filters, search and counts for the changelog and SDKs (client)
    uptime.tsx            90-day uptime bars with tooltips (client)
    toc.tsx               "On this page" for the quickstart (client)
    contact-form.tsx, signup-form.tsx, newsletter-form.tsx, use-validation.tsx, success-box.tsx
    not-found-path.tsx    the requested path in the 404 terminal (client)
    announcer.tsx, reveal-observer.tsx, back-to-top.tsx
  data/                   everything you edit to change content (see Content)
  lib/
    highlight.ts          syntax highlighter for curl, node, python, go and json
    code-lang.ts          the shared, remembered code language
    status.ts             uptime calculation and status levels
    validation.ts         form rules and the free-email list
    format.ts             number and money formats, random ids
    storage.ts, announce.ts, copy.ts, metadata.ts
  fonts/                  Host Grotesk and IBM Plex Mono woff2 files with their licenses
public/img/og-image.svg   source of the social image
next.config.ts            static export and DEMO_BASE_PATH
template.json             gallery metadata (freeframe.dev only)

Customising#

Brand and colours#

The design tokens live in the @theme block of src/app/globals.css. Every utility such as bg-ink-900, border-line, text-muted or text-brand-400 comes from them.

Token Default Used for
--color-ink-950 #09090a Page background
--color-ink-900, --color-ink-850, --color-ink-800 #0f0f10, #141415, #1a1a1c Panels, inputs and code, tab bars
--color-line, --color-line-strong #222224, #333336 Hairlines of the frame and grid; input and button borders
--color-fg #ecebe7 Headings and body text
--color-muted, --color-subtle #a8a7a1, #8c8b85 Secondary text, captions (AA on every surface)
--color-brand-200 … --color-brand-700 amber scale Primary buttons, links, focus rings, accents
--color-ok, --color-warn, --color-down, --color-info green, yellow, red, blue Status dots, uptime bars, HTTP status codes

To change the accent, edit the brand scale:

css
@theme {
  --color-brand-300: #7ee0ff;
  --color-brand-400: #3cc8ff;
  --color-brand-500: #12b3f0;
}

A few SVGs use the accent as a literal colour (#ffbe3d): the logo (src/components/logo.tsx) and favicon (src/app/icon.svg), the feature illustrations and diagram in src/app/page.tsx, the team avatars in src/app/company/page.tsx and the latency chart in src/components/latency-chart.tsx. Search for ffbe3d to change them too. The code token colours are the .t-k, .t-s, .t-n, .t-c, .t-f, .t-p and .t-d classes in globals.css.

Forge is a dark-only design (color-scheme: dark in globals.css and colorScheme: "dark" in the viewport export of layout.tsx). There is no light theme or toggle.

Layout grid#

layout.tsx wraps every page in .frame, a centred 1240px column with hairlines left and right. Sections are separated by .rule, a top border with a small cross where it meets the frame. Card grids use .cells (1px gaps that show the line colour) or .cells-open (lines drawn per card, so a short last row stays blank; used for the filtered SDK list). .pad-x gives the standard horizontal padding. .bg-plus is the faint field of plus marks behind heroes and .glow the amber light; both are decorative and aria-hidden.

Typography#

Host Grotesk (text and headings) and IBM Plex Mono (navigation, labels, buttons, numbers and code) are self-hosted with next/font/local in src/app/fonts.ts, from the exact woff2 files the HTML version loads from Google Fonts (src/fonts/, split into latin and latin-ext like Google's stylesheet). Each face keeps its real family name, so the stacks --font-sans and --font-mono in @theme and the inline SVGs can name them directly. Heading sizes are the .h-display, .h-section and .h-card classes.

To swap a font, put its woff2 files in src/fonts/, change the src and font-family declaration of the matching localFont() calls in fonts.ts, and update the stack in @theme.

Content#

Page text is in the page files under src/app/. The data behind lists and interactive parts is in src/data/:

File What it holds
code.ts LANGS (curl, node, python, go), HERO_SAMPLES (hero code and file name per language) and HERO_RESPONSE (status, latency range, JSON body, delivery lines)
quickstart.ts QUICKSTART: the tabbed code for each quickstart step, per language
explorer.ts API_BASE, INSPECTOR (endpoints with method, path, parameters, status, latency range) and DELIVERIES (sample log for GET /v1/deliveries)
latency.ts LATENCY: 24 hourly p50 and p99 values for the home page chart, and the top of its axis
pricing.tsx PLANS (the four plan cards), COMPARE (comparison table) and FAQ
pricing-calc.ts PRICING: included events, overage tiers, annual discount, Enterprise threshold and slider steps
changelog.tsx TYPES (filter types with labels and colours) and MONTHS (entries grouped by month)
sdks.ts SDK_TYPES and SDKS (the 16 library cards)
status.ts STATUS (window end date, components, regions, incidents for the uptime bars, and maintenance: the date of the scheduled maintenance notice, null for a rolling demo date, the next Thursday at least three days away, shown by <MaintenanceTime> after hydration) and HISTORY (incident timelines)
company.ts TEAM, ROLES and JOBS_EMAIL
nav.ts NAV, MOBILE_NAV and FOOTER_NAV

For example, to add an explorer endpoint, add an object to INSPECTOR and a branch for its id in respond() in src/components/explorer.tsx (and add the id to the Endpoint["id"] type):

ts
{
  id: "rotate",
  method: "POST",
  path: "/v1/endpoints/{id}/rotate",
  status: 200,
  latency: [30, 60],
  desc: "Rotate an endpoint’s signing secret.",
  params: [{ name: "id", in: "path", kind: "text", value: "ep_7Hc2Qm" }],
},

Parameters have a kind of text, number (with min and max) or select (with options), and an optional pattern with an error message and a hint. The endpoint list and the form are generated from the array.

To add a changelog entry, add an object at the top of the right month in MONTHS:

tsx
{
  date: "2026-10-20",
  label: "Oct 20",
  version: "v4.2.1",
  tags: ["fix"],
  title: "Retries respect Retry-After",
  body: <p>Delivery workers now wait for the Retry-After header of 429 and 503 responses.</p>,
},

tags are feature, improvement, fix and breaking; an optional example adds a highlighted code block. SDK cards are objects in SDKS with a type of sdk, framework or tool. Plan prices appear in PLANS and in PRICING (see Pricing calculator). The status page's uptime bars come from STATUS.incidents and its history list from HISTORY; keep them in step.

Images and artwork#

There are no photos. Everything is SVG or CSS:

  • Logo — the amber square with an F (src/components/logo.tsx); the same mark is src/app/icon.svg. The forge_ wordmark is text, and its underscore blinks on hover.
  • Customer wordmarks — inline SVG in src/app/page.tsx, each with role="img" and an aria-label.
  • Feature illustrations and the how-it-works diagram — inline SVG and HTML mock-ups in src/app/page.tsx, marked aria-hidden="true". The diagram is hidden below 768px, where the four numbered steps tell the same story.
  • Avatars — monogram squares in inline SVG (testimonials and team; the team's shapes are shape in TEAM). To use photos, replace the <svg> with an <img> of the same size class (square, at least 160×160 px) and a real alt. Put the files in public/ and remember the base path if you build with DEMO_BASE_PATH.
  • Office skylines — small inline SVGs in src/app/company/page.tsx.
  • Social image — src/app/opengraph-image.png (1200×630), picked up by Next.js for every page. Edit public/img/og-image.svg and export it to PNG at 1200×630; most social sites don't display SVG.

The header (src/components/site-header.tsx) and footer (src/components/site-footer.tsx) are rendered once by layout.tsx. To add a page, create src/app/<route>/page.tsx with a metadata export, then add it to NAV, MOBILE_NAV or FOOTER_NAV in src/data/nav.ts. Links to the current page get aria-current="page", which also draws the amber underline. The header becomes frosted after scrolling (class is-scrolled).

Features#

Code language tabs#

Every language tablist on a page shows the same language: the hero sample on / and every code card plus the language picker on /docs. The choice is saved (see Saved data), so picking Go on the home page also opens the quickstart in Go.

  • Code: LangTabList, CodeGroup and useLang() in src/components/lang-tabs.tsx; the shared state is src/lib/code-lang.ts, read with useSyncExternalStore, so the static HTML starts on curl and switches to the saved language right after hydration.
  • Highlighting: highlight() in src/lib/highlight.ts is a small tokenizer for curl (shell), node, python, go and json. Static blocks (CodeBlock) are highlighted at build time; the hero, the explorer and the sign-up snippet call it in the browser. Write samples as plain strings in src/data/.
  • Accessibility: tabs use role="tab" with aria-selected and a roving tabIndex; arrow keys, Home and End move between them. Code blocks are focusable so keyboard users can scroll them.

Typed hero sample#

HeroCode (src/components/hero-code.tsx) types HERO_SAMPLES[lang].code with live highlighting, shows "sending…", then prints the HERO_RESPONSE JSON line by line and the three delivery results. It starts when the sample is on screen, restarts when the language changes, and "Run again" (#hero-replay) replays it. The latency is a random number in HERO_RESPONSE.latency. With reduced motion everything appears at once. The response area is an aria-live="polite" region.

API explorer#

Explorer (src/components/explorer.tsx, #inspector on the home page) lets visitors pick one of the INSPECTOR endpoints, edit its parameters and send. It shows the full URL, an equivalent curl command (updated as you type), the status, latency, size, a JSON body and a Headers tab (forge-request-id, forge-version, and ratelimit-remaining counting down from 5000). A first request is sent on load so the panel is never empty.

  • Errors: empty values, numbers outside min/max and failed pattern checks return 422 with an invalid_request_error; event ids that don't match evt_… and endpoint ids that don't match ep_… return 404.
  • Responses: built by respond(). GET /v1/deliveries filters DELIVERIES by status and returns up to limit items with has_more.
  • Keyboard: Ctrl/Cmd + Enter sends from any field; the button hint shows ⌘↵ on Apple devices and Ctrl ↵ elsewhere. Endpoints are a radio group; Body and Headers are tabs.
  • Connecting a real API: replace respond() with a fetch() to your own backend proxy. Never put a secret API key in browser code.

Latency chart#

LatencyChart (src/components/latency-chart.tsx) draws LATENCY.p50 (solid amber with a fill) and LATENCY.p99 (dashed) into #lat-chart as an inline SVG with gridlines at 0, 100, 200 and 300 ms. It is drawn at the box's pixel size and redrawn on resize, so labels stay the same size. The SVG has role="img" and an aria-label with the ranges.

Pricing calculator#

src/components/pricing.tsx holds the pricing page's shared state in PricingProvider:

  • Billing switch BillingSwitch (#billing-toggle, role="switch", aria-checked). Plan cards show price or priceAnnual and note or noteAnnual from PLANS. Monthly is the default.
  • Volume slider Calculator (#calc-volume), whose positions are PRICING.steps (10k to 500M events, default step PRICING.defaultStep, 5M). For each volume, planCost() prices every plan: the base fee (minus PRICING.annualDiscount when annual), plus overage per million across the plan's tiers. estimate() picks the cheapest plan that can carry the volume (Free has no overage, so it stops at 100k). Its card gets the is-recommended outline and a "Fits your volume" badge, and the breakdown lists each tier. From PRICING.enterpriseFrom (250M) the result is Enterprise and the button links to /contact?volume=250m.

Default prices: Free $0 (100k events), Build $29 (1M included, $20 per extra million), Scale $249 (15M included, $12 per million up to 100M, then $8). At 5M events the estimate is Build at $109 a month; at 50M it is Scale at $669 (or $619 billed annually). To change prices, edit PRICING and the matching text in PLANS and COMPARE. The slider has aria-valuetext, and the result is announced politely after changes stop.

Status page#

Uptime (src/components/uptime.tsx) renders one row of 90 daily bars per component, ending on STATUS.end (UTC). uptimeDays() in src/lib/status.ts gives each day the worst incident that touched it (warn, down or info); uptime per day subtracts outage minutes in full and degraded minutes at half, weighted by how many of the selected regions were affected. Maintenance doesn't count as downtime. Everything is calculated from fixed dates, so the static HTML and the browser always agree.

  • Region switch: the [data-region] buttons (all, us-east, eu-central, ap-southeast) recalculate the bars and averages for one region.
  • Tooltips: #uptime-tip shows the date, state, uptime and incident on hover and on focus. Bars use a roving tabIndex: Tab enters a row on today's bar, arrow keys, Home and End move between days, Escape hides the tooltip. Each bar also has a full aria-label.
  • Phones: below 640px only the last 30 days are shown.
  • Current status grid: CURRENT in src/app/status/page.tsx sets a cell to warn, down or info (by component and region id); everything else is operational. Update the banner text at the top of the page to match.

src/components/filter.tsx powers the changelog and SDK directory. FilterProvider knows every item's tags (and, for SDKs, its text); FilterBar renders the buttons with counts, FilterItem hides what doesn't match, FilterGroup hides months with no visible entries, FilterSearch matches the item text 150 ms after typing stops and FilterEmpty shows when nothing matches. The type is kept in the URL (?type=fix), buttons use aria-pressed, and "Showing 3 entries" is announced (the noun prop sets the word).

Copy buttons#

CopyButton (src/components/copy-button.tsx) copies text to the clipboard; without text it copies the visible panel of its [data-code-group] or the code in its [data-copy-scope]. The label changes to "✓ Copied" for 1.8 seconds and the live region announces it.

Quickstart navigation#

Toc (src/components/toc.tsx) marks the step you're reading in the "On this page" sidebar with aria-current="true". The sidebar shows from 1024px; a help column with the base URL appears from 1280px.

FAQ accordion, menu, reveals and back to top#

Accordion keeps one answer open at a time (aria-expanded, aria-controls); without JavaScript every answer is visible. The mobile menu (#menu-toggle, #mobile-menu) uses aria-expanded, closes on Escape, on navigation and when the window grows to desktop width. Elements with .reveal fade up as they enter the viewport (reveal-d1 to reveal-d3 add a delay; RevealObserver in src/components/reveal-observer.tsx); nothing is hidden for reduced motion or without JavaScript. BackToTop appears after one screen of scrolling; remove it from layout.tsx and delete the .back-to-top styles to drop it.

Forms#

There are three forms. All use useValidation() from src/components/use-validation.tsx, which reads the rules from each field's attributes (required, type, minLength, pattern, data-validate, data-label, data-required, data-pattern-message) and applies src/lib/validation.ts: messages appear under the field, linked with aria-describedby and aria-invalid, focus moves to the first problem, and errors update as the visitor types.

Form Where Checks
Newsletter src/components/newsletter-form.tsx (footer of every page) Email required and well-formed
Talk to sales src/components/contact-form.tsx (/contact) First and last name, work email (data-validate="work-email" rejects FREE_EMAIL domains), company, volume, topic, message of 20 to 1000 characters (with a counter), consent box
Sign-up src/components/signup-form.tsx (/signup) Email, password of at least 12 characters, project name (pattern, 3–32 lowercase letters, digits or dashes), region (radio group), terms box

/contact?volume=250m (or any value in VOLUMES in contact-form.tsx) preselects the volume, and /signup?plan=build shows the plan (free, build or scale).

On a valid sign-up, the form is hidden and #key-panel shows a demo key made of KEY_PREFIX (fk_test_) and 32 random characters from crypto.getRandomValues, masked (fk_test_••••… plus the last four), with a Reveal button (aria-pressed), Copy, a ready-made curl request and "Start over". The key is never stored or sent.

The demo forms don't send anything. On success they say so. To connect a form service such as Formspree, change the callback passed to useValidation(), for example in contact-form.tsx:

tsx
const v = useValidation(async (form) => {
  const res = await fetch("https://formspree.io/f/your-id", {
    method: "POST",
    body: new FormData(form),
    headers: { Accept: "application/json" },
  });
  if (res.ok) setDone((form.elements.namedItem("first_name") as HTMLInputElement).value.trim());
});

Every field has a name, so FormData includes it. If you remove the static export (see below), you can post to a Route Handler instead (src/app/api/contact/route.ts with export async function POST(request: Request)) or call a Server Action. For a real sign-up, post the form to your own backend and show the key it returns; never generate real keys in the browser.

Saved data (localStorage)#

Key Value Validation
forge-code-lang curl, node, python or go getLang() in src/lib/code-lang.ts ignores anything else and uses curl

It stores the preferred code language for the hero sample on / and the code cards on /docs. Reading and writing go through src/lib/storage.ts, which catches errors, so blocked storage only means the choice isn't remembered. To reset it, run localStorage.removeItem("forge-code-lang") in the console; to stop saving, remove the writeStore() call in setLang().

SEO and sharing#

Each route sets its title and description with pageMeta() from src/lib/metadata.ts, which also writes the Open Graph title and description. The image is src/app/opengraph-image.png; set SITE_URL when building so its URL is absolute (metadataBase in layout.tsx):

sh
SITE_URL=https://forge.example.com pnpm build

The 404 page is noindex; configure 404.html as your host's not-found page and it shows the requested path in its terminal. Add src/app/sitemap.ts and src/app/robots.ts if you want them (both work with the static export). There is no structured data; Organization and SoftwareApplication JSON-LD are good additions.

Accessibility#

Built in:

  • Skip link to #main, landmarks (header, labelled navs, main, footer) and one h1 per page with ordered headings.
  • Visible amber focus rings and colour contrast that passes WCAG AA on every surface.
  • ARIA patterns: menu button with aria-expanded, tabs with roving tabIndex, a switch (role="switch"), accordion buttons, aria-pressed filters and region buttons, a radio group for explorer endpoints, keyboard-navigable uptime bars with tooltips, live regions for the hero response, explorer, calculator, filters and copy buttons, and labelled scrollable tables (role="region", tabIndex={0}).
  • prefers-reduced-motion: no typing, no flowing diagram lines, no blinking cursors, no reveals, instant back to top.
  • Decorative SVG and mock-ups are aria-hidden; meaningful graphics (wordmarks, the latency chart) have labels.

When editing, keep one h1 per page, give every new input a <label>, and check contrast if you change the ink, line, muted or subtle tokens.

Building and deploying#

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

next.config.ts sets output: "export", images.unoptimized and basePath from DEMO_BASE_PATH (for a sub-folder, e.g. DEMO_BASE_PATH=/forge pnpm build). Deploy out/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the repository into Vercel. demo/ is the freeframe.dev demo build and can be deleted. To use server features (Route Handlers, Server Actions, a real API behind the explorer), remove output: "export" and deploy to a Node.js host or Vercel.

TypeScript 7 setup#

@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to @typescript/typescript6 because typescript-eslint and parts of the Next.js toolchain need the TypeScript 6 API. Replace both with typescript@7 once typescript-eslint supports it.

Troubleshooting#

  • The hero code appears all at once. Your system has "reduce motion" turned on; typing is intentionally skipped.
  • Copy buttons say "Press Ctrl+C". The browser blocked clipboard access. It works on localhost and HTTPS.
  • The quickstart always opens in curl. Your browser blocks localStorage (private mode or strict settings); the tabs still sync within the page.
  • Calculator totals don't match the plan cards. The calculator reads PRICING in src/data/pricing-calc.ts; the cards and table read PLANS and COMPARE in src/data/pricing.tsx. Update both files.
  • Uptime bars and the incident list disagree. The bars come from STATUS.incidents, the list from HISTORY. Edit both, and move STATUS.end to the last day you want to show.
  • A Tailwind class written in a template string has no effect. Keep a space between a class name and a ${...} expression (`h-5 w-5 ${open ? "hidden" : ""}`), otherwise Tailwind can't find the class.
  • Fonts fall back to the system font. Check that src/fonts/ still has the woff2 files named in src/app/fonts.ts.
  • The demo's links or images break under a sub-folder. Build with DEMO_BASE_PATH set to that folder.

License#

MIT, see the repository license (included as LICENSE.txt in the download). All artwork (logo, wordmarks, avatars, diagrams and social image) is original SVG and covered by the same license. Host Grotesk and IBM Plex Mono are under the SIL Open Font License 1.1 (src/fonts/*-OFL.txt).

Search documentation

Search the guides and every template's documentation