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).
pnpm install
pnpm dev # http://localhost:3000Other 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:
@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):
{
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:
{
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 issrc/app/icon.svg. Theforge_wordmark is text, and its underscore blinks on hover. - Customer wordmarks — inline SVG in
src/app/page.tsx, each withrole="img"and anaria-label. - Feature illustrations and the how-it-works diagram — inline SVG and HTML mock-ups in
src/app/page.tsx, markedaria-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
shapeinTEAM). To use photos, replace the<svg>with an<img>of the same size class (square, at least 160×160 px) and a realalt. Put the files inpublic/and remember the base path if you build withDEMO_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. Editpublic/img/og-image.svgand export it to PNG at 1200×630; most social sites don't display SVG.
Navigation, header and footer#
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,CodeGroupanduseLang()insrc/components/lang-tabs.tsx; the shared state issrc/lib/code-lang.ts, read withuseSyncExternalStore, so the static HTML starts on curl and switches to the saved language right after hydration. - Highlighting:
highlight()insrc/lib/highlight.tsis a small tokenizer forcurl(shell),node,python,goandjson. 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 insrc/data/. - Accessibility: tabs use
role="tab"witharia-selectedand a rovingtabIndex; 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/maxand failedpatternchecks return422with aninvalid_request_error; event ids that don't matchevt_…and endpoint ids that don't matchep_…return404. - Responses: built by
respond().GET /v1/deliveriesfiltersDELIVERIESbystatusand returns up tolimititems withhas_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 afetch()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 showpriceorpriceAnnualandnoteornoteAnnualfromPLANS. Monthly is the default. - Volume slider
Calculator(#calc-volume), whose positions arePRICING.steps(10k to 500M events, default stepPRICING.defaultStep, 5M). For each volume,planCost()prices every plan: the base fee (minusPRICING.annualDiscountwhen annual), plus overage per million across the plan'stiers.estimate()picks the cheapest plan that can carry the volume (Free has no overage, so it stops at 100k). Its card gets theis-recommendedoutline and a "Fits your volume" badge, and the breakdown lists each tier. FromPRICING.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-tipshows the date, state, uptime and incident on hover and on focus. Bars use a rovingtabIndex: 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 fullaria-label. - Phones: below 640px only the last 30 days are shown.
- Current status grid:
CURRENTinsrc/app/status/page.tsxsets a cell towarn,downorinfo(by component and region id); everything else is operational. Update the banner text at the top of the page to match.
Filters and search#
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:
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):
SITE_URL=https://forge.example.com pnpm buildThe 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, labellednavs,main,footer) and oneh1per 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 rovingtabIndex, a switch (role="switch"), accordion buttons,aria-pressedfilters 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#
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
localhostand 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
PRICINGinsrc/data/pricing-calc.ts; the cards and table readPLANSandCOMPAREinsrc/data/pricing.tsx. Update both files. - Uptime bars and the incident list disagree. The bars come from
STATUS.incidents, the list fromHISTORY. Edit both, and moveSTATUS.endto 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 insrc/app/fonts.ts. - The demo's links or images break under a sub-folder. Build with
DEMO_BASE_PATHset 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).