A product's help site in an hour
You have a product (a Next.js app here) and want, by the end of the hour: docs, a blog, a community, legal pages and a status page that watches the app and its background worker, all on one help site, with readers signing in with the account they already have. This page is that hour, in order. Every step is a file you commit or a command you run; nothing is clicked.
What you need: FORUMULATE_TOKEN set to a token for your organization (Tokens and scopes), Node 20 or later, and the organization's slug.
1. The manifest
Download product-help.yaml into your repository as forumulate.yaml, next to two folders of Markdown:
forumulate.yaml
content/docs/getting-started.md
content/blog/hello.md
content/legal/terms.mdThe file whole, so you can read it before you run it:
version: 2
org: yourproduct # CHANGE: your organization's slug (or --org / FORUMULATE_ORG)
spaces:
- slug: docs
name: Docs
type: DOCS
visibility: PUBLIC
seo:
seoTitle: Your Product docs # CHANGE
description: How to set up and use Your Product, step by step. # CHANGE
- slug: blog
name: Blog
type: BLOG
visibility: PUBLIC
seo: { description: News and release notes from the Your Product team. } # CHANGE
posts: { dir: content/blog }
- slug: community
name: Community
type: FORUM
visibility: PUBLIC
seo: { description: Ask questions and share what you built with Your Product. } # CHANGE
categories:
- { name: Announcements }
- { name: Help, description: Questions about Your Product }
- slug: status
name: Status
type: STATUS
visibility: PUBLIC
seo: { description: Live status of the Your Product app, API and background jobs. } # CHANGE
monitors:
# The app's own health route (below), checked every minute; "ok" must be in the body.
- { name: App, url: https://app.yourproduct.com/api/health, keyword: '"ok":true' } # CHANGE
# The background worker pings us after each run; down after 1.5 intervals of silence.
- { name: Worker, kind: heartbeat, intervalSeconds: 300 }
- slug: legal
name: Legal
type: LEGAL
visibility: PUBLIC
seo: { description: Terms, privacy and the other agreements for Your Product. } # CHANGE
pages:
- { dir: content/docs, space: docs }
- { dir: content/legal, space: legal }
sites:
- slug: help
name: Your Product Help # CHANGE
subdomain: yourproduct # CHANGE: yourproduct.forumulate.com
spaces: [docs, blog, community, status, legal]
config:
search: pill
headerLinks:
- { label: Docs, href: /docs }
- { label: Community, href: /community }
- { label: Status, href: /status }
- { label: Open the app, href: 'https://app.yourproduct.com', style: button } # CHANGE
readerAuth: atlas # readers sign in with their Your Product accountChange the lines marked CHANGE. subdomain claims yourproduct.forumulate.com for the site; put it on your own domain later with a domains entry (Custom domains). Every key is in forumulate.yaml and apply.
2. Plan, then apply
Pin the CLI so CI runs the version you tried:
#!/bin/sh
# scripts/help-site.sh: plan on every pull request, apply on main
set -eu
CLI="npx -y @forumulate/cli@0.21.0"
if [ "${1:-plan}" = apply ]; then
$CLI apply --secrets-file ./secrets.json # the heartbeat's ping URL lands here, mode 0600
else
$CLI plan --diff --check || [ $? -eq 1 ] # 1 = changes to apply; 5 = a studio edit to pull first
fiThe first plan lists every space, page, monitor and the subdomain claim as + create (+ claim subdomain yourproduct (yourproduct.forumulate.com, site help)); the apply writes them and reads each one back. A second apply with nothing changed writes nothing.
A newer CLI prints one line on stderr when it is out (CLI 0.22.0 is available (you run 0.21.0): …); move the pin when you are ready. If someone edits a page in the studio, the next plan names the page, the editor and the time, and ends with the forumulate pull line that brings the edit into content/ (Studio edits).
Keep secrets.json out of git, and put the Worker heartbeat's ping token in your app's secret store as FORUMULATE_PING_TOKEN, and its monitor id as FORUMULATE_PING_MONITOR. Both are in the pingUrl the file holds under monitors/status/Worker: https://forumulate.com/api/status/ping/<monitorId>/<token>.
3. The health route
The App monitor fetches this every minute and reads the app as down unless the body holds "ok":true. Check what the app cannot work without, and answer fast:
// app/api/health/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
await db.$queryRaw`select 1`; // CHANGE: your own dependency check
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ ok: false }, { status: 503 });
}
}4. The worker heartbeat
After each pass of your background job, ping the monitor with the token in a header rather than in the URL, so it stays out of access logs:
// lib/heartbeat.ts
const API = process.env.FORUMULATE_URL ?? 'https://forumulate.com/api';
export async function heartbeat(failed?: string): Promise<void> {
const monitor = process.env.FORUMULATE_PING_MONITOR;
const token = process.env.FORUMULATE_PING_TOKEN;
if (!monitor || !token) return;
try {
await fetch(`${API}/status/ping/${monitor}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(failed ? { status: 'down', detail: failed } : {}),
signal: AbortSignal.timeout(10_000),
});
} catch {
// A ping that could not be sent must never fail the job; silence is what the monitor watches for.
}
}// in the worker, every 5 minutes
try {
await sendReminders();
await heartbeat();
} catch (e) {
await heartbeat((e as Error).message);
throw e;
}A run that reports status: down turns the component red at once; a worker that stops running turns it red after 7.5 minutes (one and a half intervals). The body fields are in Status monitors.
5. Link it from the app
Put the help site in your app's footer, so readers find it from where they are:
// components/footer.tsx
const HELP = 'https://yourproduct.forumulate.com'; // CHANGE
export function Footer() {
return (
<footer>
<a href={`${HELP}/docs`}>Docs</a>
<a href={`${HELP}/community`}>Community</a>
<a href={`${HELP}/status`}>Status</a>
<a href={`${HELP}/legal`}>Legal</a>
</footer>
);
}6. Check the whole thing
npx -y @forumulate/cli@0.21.0 doctor --org yourproductWith the app deployed and the worker pinging, the report reads like this (details shortened):
Checking organization yourproduct
PASS domains.served domains.served
…
PASS status.heartbeat_age status.heartbeat_age
1 heartbeat(s) on time (oldest 212 s).
PASS reader-auth.warnings reader sign-in has no warnings
No warnings on the reader sign-in setup.
SKIP consent.webhook_verified consent.webhook_verified
…
5 passed, 0 failed, 3 skippedSKIP is something you have not set up (consent, an executor), not a fault. A FAIL names its fix; a heartbeat that has not pinged reads Worker never pinged (not set up yet) until step 4 is deployed. Run the same command on a schedule in CI: it exits 1 on any FAIL.
Next
Your own domain instead of the subdomain: Custom domains.
An API reference from your OpenAPI, consent, support chat: Putting a product on Forumulate.
Every manifest key:
forumulate.yamland apply.