A product's help site in an hour

Embed
Preview of the card
<a href="https://forumulate.com/guides/product-help-site"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/c860d8e2-a2f8-4cbc-a8cd-273225db9fd9/social-card.svg?v=3e7ae33eec8b" alt="A product's help site in an hour" width="600" height="315"></a>

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.md

The 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 account

Change 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
fi

The 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.

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 yourproduct

With 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 skipped

SKIP 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

The Forumulate teamUpdated