standards
Status pageCookbooks

Status page

Declare five objects, sync them once, and serve a public status page that keeps answering when Standards is unreachable.


Source: andyoucreate/standards-status · Next.js 16 · @stndrds/client · deploys to Vercel.

Every snippet below is lifted from that repository. Function names, exit codes and cache settings are the ones that run in production.

Snippets keep their file path in a comment and elide the local imports (log, errorFields, the Deps types); follow the path for the whole file.

What you will build

A public status page. Services, checks and incidents live in your workspace. The app does three things: it pings, it stores, it renders.

  • Cron, every 5 min → pings each enabled service → writes checks and daily-stats records → saves a snapshot → expires the page cache → warms /.
  • Visitor → CDN → page reads Standards through a 60 s cache → on failure, serves the last snapshot.
  • Your team → creates incidents and incident-updates in the Standards app. No deploy.

Prerequisites

  • A Standards workspace.
  • An API key whose role grants architect:update (used once, by the sync) and read and write on records.
  • A Vercel project for the cron and the snapshot store. Locally, the live path alone is enough.

Three environment variables, plus one Vercel injects when a Blob store is linked:

STANDARDS_API_URL=https://api.standards.new/v1
STANDARDS_API_KEY=stndrds_your_api_key
CRON_SECRET=a-long-random-string
BLOB_READ_WRITE_TOKEN=          # optional: without it there is no offline fallback

The repository's .env.example ships the URL without /v1; add it, because the hosted API answers 404 at the bare origin.

Declare the five objects

Services are what you watch. Checks are raw pings. Daily stats are one row per service and UTC day, so the 90-day bars never scan raw checks. Incidents and their updates are written by people, in the app.

// src/standards/schema.ts
import {
  checkbox,
  date,
  defineSchemaSource,
  number,
  object,
  relation,
  text,
} from "@stndrds/client";

export const service = object({ name: "services", label: "Service" })
  .pluralLabel("Services")
  .icon("globe")
  .description("A URL the status page pings every five minutes.")
  .attribute(text({ name: "name", label: "Name" }).icon("globe").required())
  .attribute(text({ name: "url", label: "URL" }).url().icon("link").required())
  .attribute(number({ name: "expectedStatus", label: "Expected HTTP status" }).defaultValue(200))
  .attribute(number({ name: "position", label: "Position" }))
  .attribute(checkbox({ name: "enabled", label: "Enabled" }).defaultValue(true))
  .attribute(
    relation({ name: "incidents", label: "Incidents" })
      .to("incidents")
      .many()
      .bilateral({ object: "incidents", attribute: "services" })
  )
  .labelExpression("{{ name }}");

export const check = object({ name: "checks", label: "Check" })
  .pluralLabel("Checks")
  .description("One ping result. Written by the status page, kept for seven days.")
  .attribute(relation({ name: "service", label: "Service" }).to("services").required())
  .attribute(checkbox({ name: "ok", label: "OK" }))
  .attribute(number({ name: "statusCode", label: "HTTP status" }))
  .attribute(number({ name: "latencyMs", label: "Latency (ms)" }))
  .attribute(text({ name: "error", label: "Error" }))
  .attribute(date({ name: "checkedAt", label: "Checked at" }).includeTime().required())
  .labelExpression("{{ checkedAt }}");

export const dailyStat = object({ name: "daily-stats", label: "Daily stat" })
  .pluralLabel("Daily stats")
  .description("One row per service and UTC day, feeding the 90-day uptime bars.")
  .attribute(relation({ name: "service", label: "Service" }).to("services").required())
  .attribute(date({ name: "day", label: "Day" }).required())
  .attribute(number({ name: "total", label: "Checks" }))
  .attribute(number({ name: "failed", label: "Failed" }))
  .attribute(number({ name: "responded", label: "Responded" }))
  .attribute(number({ name: "avgLatencyMs", label: "Average latency (ms)" }))
  .labelExpression("{{ day }}");

incidents carries a status attribute (investigating, identified, monitoring, resolved), an impact select, the bilateral services relation, startedAt and resolvedAt. incident-updates links back to its incident with a richtext message and postedAt.

The views ship with the source: a kanban on incidents ordered investigating → identified → monitoring → resolved, filtered tabs (Monitored and Paused on services, Failures on checks), grouped detail forms, and related tables from a service to its incidents, checks and daily stats.

// src/standards/schema.ts (end of file)
export const statusSource = defineSchemaSource("status", {
  objects: [service, check, dailyStat, incident, incidentUpdate],
  views,
});

Sync once, from a script

pnpm schema:sync runs scripts/sync-schema.ts with .env.local loaded. It exits 0 when the source is applied or already up to date, 1 on a name conflict, a rejected definition or a refused key. Anything else is rethrown.

// src/standards/sync-schema.ts
import {
  SchemaSourceConflictError,
  SchemaSourceValidationError,
  type Standards,
  StandardsAuthError,
} from "@stndrds/client";
import { statusSource } from "./schema";

/** Pushes the source; 0 on applied/unchanged, 1 on a conflict, a rejected definition or a refused key. */
export async function syncSchema(standards: Pick<Standards, "schema">): Promise<number> {
  try {
    const result = await standards.schema.sync(statusSource);
    log.info(result.applied ? "schema.applied" : "schema.unchanged", { hash: result.hash });
    return 0;
  } catch (error) {
    if (error instanceof SchemaSourceConflictError) {
      log.error("schema.conflict", { objectName: error.objectName, owner: error.owner });
      return 1;
    }
    if (error instanceof SchemaSourceValidationError || error instanceof StandardsAuthError) {
      log.error("schema.rejected", errorFields(error));
      return 1;
    }
    throw error;
  }
}
// scripts/sync-schema.ts
process.exitCode = await syncSchema(getStandards());

Deploying before syncing is fine: the page shows a "not set up yet" notice until the objects exist, then picks them up within a minute.

Ping and write checks

Vercel calls GET /api/check every five minutes (vercel.json: "schedule": "*/5 * * * *"). The handler checks Authorization: Bearer <CRON_SECRET>, then runChecks fetches the enabled services, pings them in parallel with a 10 s timeout, and writes one checks record per result.

The services are read with fetchAll (src/standards/paginate.ts), the pagination walk shown on Records: the API answers 20 records by default and never more than 100 per call.

// src/check/run-checks.ts
export async function runChecks(deps: RunChecksDeps): Promise<RunChecksResult> {
  const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  const checkedAt = deps.now();

  const services = await fetchAll(deps.standards.from(service).eq("enabled", true));

  const pings = await Promise.all(
    services.map(async (record) => ({
      record,
      result: await pingUrl(record.url, record.expectedStatus ?? 200, timeoutMs),
    }))
  );

  const writes = await Promise.allSettled(
    pings.map(({ record, result }) => recordCheck(deps.standards, record.id, result, checkedAt))
  );

  let writeErrors = writes.filter((w) => w.status === "rejected").length;
  let purged = 0;
  try {
    purged = await purgeOldChecks(deps.standards, daysAgo(checkedAt, RETENTION_DAYS));
  } catch (error) {
    writeErrors += 1;
  }

  const up = pings.filter((p) => p.result.ok).length;
  return { checked: pings.length, up, down: pings.length - up, writeErrors, purged };
}

Each write is one typed create on checks, followed by the daily aggregate. A failed write is counted in writeErrors, never thrown: one bad service must not stop the run. runChecks throws only when Standards itself fails on the initial read.

Aggregate the day

upsertDailyStat keeps one daily-stats record per service and UTC day: total, failed, responded (checks that got an HTTP response) and a running avgLatencyMs over the responded ones. single() returns null when the day has no row yet, so the first check of the day creates it.

// src/check/run-checks.ts
async function upsertDailyStat(
  standards: StandardsRecords,
  serviceId: string,
  result: PingResult,
  day: string
): Promise<void> {
  const stats = standards.from(dailyStat);
  const existing = await stats.eq("service", serviceId).eq("day", day).single();
  const responded = result.statusCode === null ? 0 : 1;
  if (!existing) {
    await stats.create({
      service: serviceId,
      day,
      total: 1,
      failed: result.ok ? 0 : 1,
      responded,
      ...(responded ? { avgLatencyMs: result.latencyMs } : {}),
    });
    return;
  }
  const previousResponded = existing.responded ?? 0;
  const previousAvg = existing.avgLatencyMs ?? 0;
  const nextResponded = previousResponded + responded;
  const avgLatencyMs = responded
    ? Math.round((previousAvg * previousResponded + result.latencyMs) / nextResponded)
    : previousAvg;
  await stats.update(existing.id, {
    total: (existing.total ?? 0) + 1,
    failed: (existing.failed ?? 0) + (result.ok ? 0 : 1),
    responded: nextResponded,
    ...(nextResponded === 0 ? {} : { avgLatencyMs }),
  });
}

Retention runs in the same call. Raw checks older than seven days are purged, at most 100 per run: the API caps a list at 100 records, and the cron runs again in five minutes.

// src/check/run-checks.ts
/** Raw checks older than this are purged; daily stats keep the history. */
const RETENTION_DAYS = 7;
/** One page per run: the API caps a list at 100 records, and the cron runs again in five minutes. */
const PURGE_BATCH = 100;

async function purgeOldChecks(standards: StandardsRecords, cutoff: Date): Promise<number> {
  const old = await standards
    .from(check)
    .lt("checkedAt", cutoff.toISOString())
    .limit(PURGE_BATCH)
    .fetch();
  for (const record of old.records) {
    await standards.from(check).delete(record.id);
  }
  return old.records.length;
}

Render with a cache and a fallback

The page reads a "use cache" view with a 60 s lifetime and the status tag. After every run, the cron expires that tag outright with revalidateTag(STATUS_CACHE_TAG, { expire: 0 }), then requests / from after() so the rebuild is paid by the cron, not by whoever arrives next. A fresh check, or an incident edited in Standards, is on the page as soon as the run ends. Visitors are served from the CDN and never hit Standards.

// src/status/load-status.ts
import { cacheLife, cacheTag, revalidateTag } from "next/cache";

export const STATUS_CACHE_TAG = "status";

/** `expire: 0` expires the entry outright, so the first request after a run pays the rebuild rather than serving the previous generation. */
export function revalidateStatus(): void {
  revalidateTag(STATUS_CACHE_TAG, { expire: 0 });
}

/** Cached view of the whole page; the cron route revalidates the tag after each run. */
export async function loadStatusView(): Promise<StatusView> {
  "use cache";
  cacheLife({ stale: 60, revalidate: 60, expire: 600 });
  cacheTag(STATUS_CACHE_TAG);
  const now = new Date();
  const snapshot = await resolveStatus({
    fetchSnapshot: () => fetchSnapshot(getStandards(), now),
    store: getSnapshotStore(),
    now,
  });
  return deriveStatusView(snapshot, now);
}

resolveStatus is the three-way degradation contract. The snapshot it returns carries an availability:

  • live: the fetch succeeded.
  • stale: Standards is unreachable (status: 0, a 5xx, or a key that no longer works). The page serves the last snapshot the cron saved to Vercel Blob, marked with the time it was taken.
  • unavailable: no snapshot exists, or the objects are missing. The page renders an explicit "temporarily unavailable" state, still with HTTP 200.

A 404 on a records route means the workspace answers but has none of our objects: pnpm schema:sync was never run. That case never serves a stale snapshot; the objects are gone, not the network.

degradedReason sorts the error with the two predicates from SDK / Errors, isStandardsUnreachable and isSchemaMissing.

// src/status/resolve-status.ts
/** The `reason` a degraded snapshot carries when the schema source was never synced to this workspace. */
export const SCHEMA_MISSING = "schema_missing";

export async function resolveStatus(deps: ResolveStatusDeps): Promise<StatusSnapshot> {
  try {
    return await deps.fetchSnapshot();
  } catch (error) {
    const reason = degradedReason(error);
    if (reason === null) throw error;
    log.warn("status.standards_unavailable", { reason, ...errorFields(error) });
    const last = reason === SCHEMA_MISSING ? null : await deps.store.load();
    if (last) return { ...last, availability: "stale", reason };
    return unavailableSnapshot(deps.now.toISOString(), reason);
  }
}

The snapshot store is Vercel Blob at status/snapshot.json. Without BLOB_READ_WRITE_TOKEN, save and load are no-ops and only the live path applies.

Report an incident

Everything happens in the Standards app, with no deploy:

  1. Create an incidents record: title, status (investigating, identified or monitoring), impact (none, minor, major, critical), startedAt, and link the affected services. The banner takes the label and colour of the strongest open impact, and each linked service inherits it.
  2. Add incident-updates records as you progress: a status, a message and postedAt. They show newest first on the incident card.
  3. Set status to resolved and fill resolvedAt. The incident moves to the collapsed past-incidents list for 14 days.

The next cron run expires the cache, so the page reflects the edit within five minutes.

Errors you will meet

ErrorWhat it meansFix
StandardsAuthErrorThe key is refused. schema:sync exits 1; the cron answers 500; the page degrades with reason auth.Create the key from an account whose role grants architect:update and record read/write, or fix the key in .env.local.
SchemaSourceConflictErrorAnother source already owns an object named services (or one of the other four). schema:sync exits 1 and logs objectName and owner.Rename yours, or remove the object from the other source.
StandardsRequestError with status: 404The workspace answers but has none of the five objects: the source was never synced. The page shows "not set up yet"; the cron answers 503 schema_missing.Run pnpm schema:sync with the same variables.
StandardsRequestError with status: 429The cron writes faster than the key is allowed to. A refused write is counted in writeErrors and logged; the run continues.Lengthen the schedule in vercel.json, or pause services you do not need.

Run it

git clone https://github.com/andyoucreate/standards-status && cd standards-status
pnpm install
cp .env.example .env.local   # STANDARDS_API_URL (with /v1), STANDARDS_API_KEY, CRON_SECRET
pnpm schema:sync
pnpm dev

Trigger a check by hand against your deployment, with the same secret the cron sends:

curl -s -H "Authorization: Bearer $CRON_SECRET" https://your-status-page.vercel.app/api/check

One-click deploy

The Deploy with Vercel button asks for the three variables and links a Blob store, which injects BLOB_READ_WRITE_TOKEN for the offline fallback. Sync the schema from your machine afterwards; the page picks the objects up within a minute.

Next steps

  • Agent workflow: let Claude Code report the next incident for you.
  • Records: the full query API used by the cron and the page.
  • Sync: what schema.sync returns and every error it throws.
  • Schema sources: what happens to records when an object leaves the source.