How to build a status page
How to build a status page with named components, a public board, and incidents you post by hand. Follow the prompts, check each step, and skip SMS.
You can learn how to build a status page as named components and incidents you write by hand. Statuspage is that product. The catalog line is “Green dots on good days. Honest updates on bad days.”
You pay $29–$399 a month. That is $348–$4,788 a year. The verdict is “A perfect tiny app”. The first estimate is 3 hours: public page, component states, incident posts.
I already wrote the teardown, Build a Statuspage alternative. This guide is the walk. Prompts in order, then what to look at.
The audience is a SaaS company telling customers whether the product is up. If you need SMS at scale, or a private page for one enterprise account, keep paying. The same v1 also replaces Instatus.
How to build a status page without a probe network
The Statuspage page splits the work into three rows.
The first row is the one you want. Three named components. A current state on each. Incidents you open and close yourself. A public URL. That is the 3-hour v1.
The second row is 8 hours. Maintenance windows, 90-day history, email and RSS. Do that after someone has opened the board on a bad day.
The third row is 2 weeks and up. SMS at scale, automation hooks, multi-tenant hosting. Skip it.
If you tell an agent to “clone Statuspage”, it aims at the third row. Pin every prompt to the first one.
Here is the shape of the data. Nothing else has to exist at 3 hours.
const page = {
domain: 'status.flaviocopes.com',
title: 'flaviocopes.com status',
timezone: 'Europe/Rome',
}
const components = [
{ id: 'api', name: 'API', position: 1, state: 'operational' },
{ id: 'dashboard', name: 'Dashboard', position: 2, state: 'operational' },
{ id: 'webhooks', name: 'Webhooks', position: 3, state: 'operational' },
]
A visitor hits https://status.flaviocopes.com and sees those three rows. When the API gets slow, you add an incident. The page explains it.
The card lists Component, Incident, IncidentUpdate, Maintenance, and Subscriber. Tonight you need the first three. Maintenance and Subscriber wait.
Pick the stack you already write
Every product prompt ships in four flavors: AHA (Astro, HTMX, Alpine.js), Next.js, Laravel, and Rails. The capability line for Statuspage is Astro, D1, and Workers.
This site is an Astro app on Cloudflare, so that is the path I default to. Use Next.js if that is your weekday language.
Open Build your own Statuspage. The guardrail is the useful sentence: keep an append-only incident history, and show all times with an explicit timezone. The non-goal sits next to it. No automated monitoring, no SMS, no private pages, no multi-tenant reseller features.
Pick one stack and stay on it.
Prompt 1. Scaffold Upboard and stop
Copy the AHA prompt from the product page. Paste it into your coding tool. Put this block above it.
Ship the 3-hour row only. Public page, component
states, incident posts.
Project name: Upboard.
Owner is one person. Readers do not create accounts.
Do not add scheduled maintenance yet.
Do not add email or RSS subscriptions yet.
Do not add automated monitoring, SMS, private pages,
or multi-tenant hosting.
When the plan is done, stop and wait. Do not write
implementation code until I say go.
The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.
You want pages like these:
/adminfor overall state, ordered components, and active incidents/admin/componentsfor names, order, and a support link/admin/incidents/newto open an incident/admin/incidents/inc-41for the append-only editor/for the public board onstatus.flaviocopes.com
You want tables like components, incidents, incident_updates. maintenance should not appear. subscribers should not appear.
If the plan includes a probe URL, Twilio, or a tenants table, send it back. Paste the non-goal again.
When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.
What to check after the scaffold
Start the app. Sign in as the owner. You should see an empty overview and a create-incident action that still has nothing to point at.
Open the migrations. components.id is stable. There is no state column the admin is meant to type by hand on a live component that an incident still owns. If the README is a placeholder, stop and make the agent write a real one before you add logic.
Prompt 2. Name the components and publish green
Now fill the board. Keep the public URL all-operational until you post an incident.
Implement components and the public board for Upboard.
The owner can save title, support URL, and timezone.
Timezone is Europe/Rome in v1. The public URL is
https://status.flaviocopes.com.
The owner can add, rename, reorder, and archive
components. Each component has id, name, position.
Displayed state is derived later. Types of state in
v1: operational, degraded, partial outage, major
outage. Never use color alone for those four.
Seed this page:
- title flaviocopes.com status
- timezone Europe/Rome
- support https://flaviocopes.com
- components in this order:
api / API
dashboard / Dashboard
webhooks / Webhooks
Phone-sized preview uses the same template as the
live board. Publishing the seed leaves the public
URL all-operational, with a current-summary line
and the three rows.
Done when I can save those fields, refresh, and see
the same names and order in the admin and on
https://status.flaviocopes.com.
The preview has to share the public template. A second “admin preview” is how you ship a board that looks fine at a desk and wraps badly on a phone.
What to check after the board is green
Create the three components. Refresh. API, Dashboard, Webhooks stay in that order. The preview is 390 pixels wide, not a desktop column.
Change Dashboard to Customer dashboard. The preview follows it. The public URL still reads all-operational.
If the agent adds a ping URL field, a Twilio token, or a “subscribers” tab, delete those screens. They belong to later rows.
Prompt 3. Post an incident you cannot edit
The official journeys mention investigation, identified, then a resolve. Stay on that. The mini-prompt is “The incident log you can’t quietly edit”.
Build incident publishing for
https://status.flaviocopes.com.
The owner opens an incident with a title, affected
components, a severity per component, a phase, and
an update body. Phases in v1: investigating,
identified, monitoring, resolved.
Publishing an update never edits or deletes a prior
entry. A correction is a new update that says what
was wrong. Order by server timestamp, newest first.
Label every time as 18:04 Europe/Rome, not a bare
clock.
Commit the update row, the incident phase, and the
recomputed component states in one transaction. A
refresh mid-publish must never show a red API with
no incident, or an incident with stale green dots.
Purge the public cache after the commit, not before.
A failed save keeps the draft body in the editor.
Seed this incident once the board is public:
- id inc-41
- title API latency in eu-west
- api degraded
- first update investigating /
API latency is up in eu-west. Dashboard is fine.
Then post identified /
Queue depth in the API worker. Webhooks are fine.
Do not send email yet. Do not add maintenance.
Done when a reader sees the yellow API, the
timeline, and 18:04 Europe/Rome, and there is no
edit control on the investigating line.
Agents give you an incident form with an Edit button on every old update. Drop that button. A published outage cannot be softened after the fact.
async function publishUpdate(db, incidentId, { phase, body, components }) {
const update = {
incidentId,
phase,
body,
publishedAt: new Date().toISOString(),
}
await db.transaction(async (tx) => {
await tx.insertUpdate(update)
await tx.setIncidentPhase(incidentId, phase)
await tx.recomputeComponentStates(components)
})
await purgePublicCache('status.flaviocopes.com')
return update
}
await publishUpdate(db, 'inc-41', {
phase: 'identified',
body: 'Queue depth in the API worker. Webhooks are fine.',
components: [{ id: 'api', severity: 'degraded' }],
})
Cache purges fire after the commit. If you purge first and the write fails, the public page can flash the new state and then bounce back.
Label the clock from the stored instant and the page timezone.
function labelTime(iso, timeZone) {
const clock = new Intl.DateTimeFormat('en-GB', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(iso))
return `${clock} ${timeZone}`
}
labelTime('2026-10-24T16:04:00.000Z', 'Europe/Rome')
//'18:04 Europe/Rome'
Store UTC. Keep Europe/Rome on the page. A bare 18:04 hides which Tuesday you meant.
What to check after the first incident
Open https://status.flaviocopes.com in a private window. The summary is not all-operational. API reads degraded. Dashboard and Webhooks stay operational.
The timeline shows investigating, then identified, newest first. Each stamp includes Europe/Rome.
Try to PATCH the investigating body in the inspector. The server refuses. Post a correction as a third update instead. The old lines stay.
Mash publish on identified. You still have one identified row.
If the public page shows a red API and an empty incident list, the write split across two requests. Send prompt 3 back.
Prompt 4. Two incidents, one honest state
The other failure agents miss is treating component state as a field you type.
The mini-prompt is “Two outages, one component, honest math”. Each incident declares affected components with a severity. The displayed state is derived. It is the worst severity across every active incident that touches that component.
Derive component state. Do not let the admin type
the live state of a component an incident still owns.
Severity order, low to high:
operational, degraded, partial outage, major outage.
When any incident is created, updated, or resolved,
recompute each affected component from the remaining
active incidents. Only zero active hits return a
component to operational.
The public summary is the worst state across every
component, using the same order.
Seed a second incident:
- id inc-42
- title API packet loss in eu-west
- api major outage
- first update investigating /
Packet loss on the API edge. Dashboard is fine.
Resolve inc-42. API must drop to degraded, not
operational, because inc-41 is still open. Resolve
inc-41. API returns to operational. The public
summary follows the same math.
Done when overlapping incidents leave the worst
leftover state, resolving both restores green, and
there is no admin field that writes component.state
directly.
Two incidents both hit the API. One is a major outage. One is degraded. You resolve the major one. The API must drop to degraded, not to operational, because something is still wrong.
const SEVERITY = {
operational: 0,
degraded: 1,
'partial outage': 2,
'major outage': 3,
}
function worstState(activeIncidents, componentId) {
let worst = 'operational'
for (const incident of activeIncidents) {
const hit = incident.components.find((item) => item.id === componentId)
if (!hit) continue
if (SEVERITY[hit.severity] > SEVERITY[worst]) {
worst = hit.severity
}
}
return worst
}
const active = [
{
id: 'inc-41',
components: [{ id: 'api', severity: 'degraded' }],
},
{
id: 'inc-42',
components: [{ id: 'api', severity: 'major outage' }],
},
]
worstState(active, 'api')
//'major outage'
worstState(active.filter((item) => item.id !== 'inc-41'), 'api')
//'major outage'
worstState(active.filter((item) => item.id !== 'inc-42'), 'api')
//'degraded'
Recompute on create, on update, and on resolve. The public summary uses the same order over every component.
function pageSummary(components) {
let worst = 'operational'
for (const component of components) {
if (SEVERITY[component.state] > SEVERITY[worst]) {
worst = component.state
}
}
return worst
}
pageSummary([
{ id: 'api', state: 'degraded' },
{ id: 'dashboard', state: 'operational' },
{ id: 'webhooks', state: 'operational' },
])
//'degraded'
What to check after the overlap
Open both incidents. API reads major outage. Resolve inc-42. API reads degraded. The investigating line on inc-42 is still there.
Resolve inc-41. API is operational. The public summary is all-operational again. The two timelines stay on the page under recent incidents, even though nothing is open.
If resolving inc-42 paints the API green while inc-41 is still open, state is a stored field. Point it at worstState.
Failures the agent ships anyway
An Edit button on old updates
A status page that lets you rewrite “investigating” after the outage is over is not credible. Corrections append. There is no mutate path.
A red dot with no story
Publish and state change in one transaction. A refresh in the middle of two writes is how customers see a red API and an empty board.
Color as the only signal
Green, yellow, red looks fine until someone is color-blind, or you print the page, or dark mode shifts the yellow. Put the word next to the dot. Operational. Degraded. Partial outage. Major outage.
A probe that emails you
Automated monitoring is the non-goal. You write the incident. A ping that emails you when a URL dies is UptimeRobot, $24–$69 a month, about 6 hours.
Reader accounts
Readers open a URL, so magic links for them are a second app.
A missed cron is a different job. That is Cronitor, $10–$49 a month, about 6 hours. Heartbeats, not a public board.
Deploy the public board
The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.
Upboard wants Astro, D1, and Workers. Put it on a subdomain you already own, status.flaviocopes.com. The AHA prompt points at Cloudflare D1 for the tables and Workers for the endpoints.
Attach the custom domain on the Worker. The public URL is the product, so status.flaviocopes.com has to resolve on the public internet, not only on localhost.
Ship with the seed components published. Open an incident from your laptop. Confirm a phone on cellular sees the yellow API and the timezone label after a refresh. Link the board from the product footer and the support page. Replace the old Statuspage URL in emails you still send by hand.
If you miss a maintenance window or an RSS feed, those are the 8-hour row and the other mini-prompts on the card. Add them after a reader asks.
Take a D1 backup, then cancel Statuspage, or Instatus, whichever one you pay for.
What to skip on purpose
Leave out automated monitoring, SMS, a private customer page, and anything that looks like multi-tenant reseller hosting.
Scheduled maintenance is the honest next feature, not a v1 requirement. Store the local times the admin typed, the named IANA timezone, and the UTC instants. The Statuspage walkthrough covers that 8-hour row.
A short URL in front of the board is a different job. That is Bitly, $35–$300 a month, about 90 minutes. The link shortener guide is that walk.
A one-question intake after an incident, for people who want a callback, is the Typeform guide. Four questions and a table, not a status board.
More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.
Start now
Go to Build your own Statuspage. Copy the AHA prompt, or the stack you already write.
Tell the agent the first estimate only: public page, component states, incident posts. Three hours.
Publish status.flaviocopes.com. Point the product footer at it tonight.