✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem18 min read

How to Build a Public Status Page (Statuspage.io Alternative) with Strapi 5

July 22, 2026

When your API goes down, the second thing your users do (right after cursing) is check your status page. If you don't have one, they open a support ticket instead. A public status page cuts that support noise and buys you trust during an outage, which is exactly when trust is scarce.

Commercial tools like Statuspage.io handle this well. If you already run a Strapi backend and a Next.js frontend, you can build your own status page in an afternoon: full control over the content model, your own hosting, and no per-seat pricing. Self-hosting is a trade-off: you take on the hosting and maintenance yourself, but in exchange you own your data outright and remove per-subscriber costs that scale badly as your audience grows. This tutorial walks through the whole build, from content types to a live server-rendered page.

In brief:

  • Model Service, Incident, and IncidentUpdate content types in Strapi 5 using the schema.json format and relations.
  • Expose them as a read-only public API and consume them from a Next.js App Router Server Component.
  • Strapi 5's flattened response format and documentId keep your fetch code clean and avoid the v4 patterns that break.
  • Caching via next: { revalidate } keeps the page fresh without hammering the backend.

What we're building

The end product is a single public page that shows an overall system status banner, a list of monitored services with their current state, and a feed of recent incidents. Each incident carries a status (investigating, identified, monitoring, resolved), an impact level, the services it affects, and a chronological list of updates. This mirrors the core of what Atlassian's Statuspage offers: components with five status states and incidents with four. The three-content-type split mirrors how real on-call teams communicate. Services represent the stable pieces of your infrastructure that rarely change. Incidents are time-bounded events that open and close. Updates form the running commentary that keeps users informed as an outage evolves, from the first "we're looking into it" to the final "resolved."

On the Strapi side, you use Collection Types defined through schema.json files, relations between content types, the Draft and Publish feature so you can stage incident updates before they go live, and the public Users and Permissions role to expose read-only endpoints. On the Next.js side, you use an App Router Server Component that fetches directly from the Strapi REST API and revalidates on a schedule.

What you'll learn:

  • Defining Strapi 5 content types and relations in schema.json
  • Configuring public read-only permissions and CORS for a Next.js frontend
  • Consuming the flattened Strapi 5 REST response with typed fetch helpers
  • Rendering everything in a Next.js Server Component with ISR revalidation

Prerequisites

Pin these versions so your code matches the APIs in this tutorial:

You'll also need a supported database. SQLite works for local development and needs Python installed. For production you'd switch to PostgreSQL 14.0+, MySQL 8.0+, or MariaDB 10.3+.

Background knowledge assumed: you're comfortable with JavaScript and TypeScript, and you've used a REST API before. No prior Strapi experience required.

Setting up the Strapi backend

Step 1: Install Strapi

Create a new Strapi project with the --non-interactive flag to skip all prompts and use TypeScript, SQLite, and git defaults:

npx create-strapi@latest status-backend --non-interactive

Once the build finishes, Strapi opens http://localhost:1337/admin. Register your first admin user there. That account is local to your machine, so pick anything you'll remember.

Leave that server running. Everything in the next few steps happens either in the Admin Panel or in files under status-backend/src.

Step 2: Define the Service content type

A service (Statuspage calls it a component) is one part of your infrastructure: the API, the dashboard, the CDN. Each has a name, an optional description, and a status drawn from the five component states Statuspage uses.

You can build this in the Content-Type Builder UI, but writing the schema.json directly is faster and shows exactly what Strapi generates. Create the file at status-backend/src/api/service/content-types/service/schema.json:

{
  "kind": "collectionType",
  "collectionName": "services",
  "info": {
    "singularName": "service",
    "pluralName": "services",
    "displayName": "Service",
    "description": "A monitored part of your infrastructure"
  },
  "options": {
    "draftAndPublish": true
  },
  "pluginOptions": {},
  "attributes": {
    "name": {
      "type": "string",
      "required": true
    },
    "description": {
      "type": "text"
    },
    "status": {
      "type": "enumeration",
      "enum": [
        "operational",
        "degraded_performance",
        "partial_outage",
        "major_outage",
        "under_maintenance"
      ],
      "default": "operational",
      "required": true
    }
  }
}

The singularName and pluralName drive both the database collection and the API routes, so services becomes /api/services. The status enum matches the exact five states a status page needs.

Step 3: Define the Incident and IncidentUpdate content types

An incident represents an active or resolved problem. It has a title, a lifecycle status (one of four incident statuses), a relation to the services it affects, and a list of timestamped updates.

Start with the incident schema. Create the file at status-backend/src/api/incident/content-types/incident/schema.json and note the two relation fields:

{
  "kind": "collectionType",
  "collectionName": "incidents",
  "info": {
    "singularName": "incident",
    "pluralName": "incidents",
    "displayName": "Incident",
    "description": "An active or resolved service incident"
  },
  "options": {
    "draftAndPublish": true
  },
  "pluginOptions": {},
  "attributes": {
    "title": {
      "type": "string",
      "required": true
    },
    "status": {
      "type": "enumeration",
      "enum": ["investigating", "identified", "monitoring", "resolved"],
      "default": "investigating",
      "required": true
    },
    "impact": {
      "type": "enumeration",
      "enum": ["none", "minor", "major", "critical"],
      "default": "minor",
      "required": true
    },
    "services": {
      "type": "relation",
      "relation": "manyToMany",
      "target": "api::service.service",
      "inversedBy": "incidents"
    },
    "updates": {
      "type": "relation",
      "relation": "oneToMany",
      "target": "api::incident-update.incident-update",
      "mappedBy": "incident"
    }
  }
}

The services field is a many-to-many relation because one incident can affect several services, and a service can be caught up in more than one incident. The updates field is a one-to-many relation that uses mappedBy to point at the field on the other side. The target values use Strapi's full UID format (api::service.service), documented in the models reference.

Now the update content type. Each update is a short message posted as the incident progresses, with its own reverse relation back to the incident. Create the file at status-backend/src/api/incident-update/content-types/incident-update/schema.json:

{
  "kind": "collectionType",
  "collectionName": "incident_updates",
  "info": {
    "singularName": "incident-update",
    "pluralName": "incident-updates",
    "displayName": "Incident Update",
    "description": "A timestamped update posted during an incident"
  },
  "options": {
    "draftAndPublish": true
  },
  "pluginOptions": {},
  "attributes": {
    "body": {
      "type": "text",
      "required": true
    },
    "incident": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "api::incident.incident",
      "inversedBy": "updates"
    }
  }
}

The inversedBy on this side pairs with mappedBy on the incident to make the relation bidirectional. In Strapi, bidirectional relations use an owning side and an inverse side; update relations explicitly with connect, disconnect, or set rather than assuming the other side will automatically appear in sync everywhere.

You'll also need to update the service schema file to complete the many-to-many relation from the incident side. Replace the contents of status-backend/src/api/service/content-types/service/schema.json with:

{
  "kind": "collectionType",
  "collectionName": "services",
  "info": {
    "singularName": "service",
    "pluralName": "services",
    "displayName": "Service",
    "description": "A monitored part of your infrastructure"
  },
  "options": {
    "draftAndPublish": true
  },
  "pluginOptions": {},
  "attributes": {
    "name": {
      "type": "string",
      "required": true
    },
    "description": {
      "type": "text"
    },
    "status": {
      "type": "enumeration",
      "enum": [
        "operational",
        "degraded_performance",
        "partial_outage",
        "major_outage",
        "under_maintenance"
      ],
      "default": "operational",
      "required": true
    },
    "incidents": {
      "type": "relation",
      "relation": "manyToMany",
      "target": "api::incident.incident",
      "mappedBy": "services"
    }
  }
}

Restart the Strapi server so it picks up the new schema files. If you use the Content-Type Builder UI or the CLI code generator with API bootstrapping on, Strapi generates the matching controllers, routes, and services for you, so there's no boilerplate to write by hand. If you add schema files manually, you'll need to create these files yourself.

Step 4: Set public read-only permissions

Every content type in Strapi is private by default. A request with no token gets the public role, and that role starts with zero access. For a status page you want the public role to read services and incidents but nothing more. For a deeper look at how Strapi handles API authorization, check the linked guide.

In the Admin Panel:

  1. Go to SettingsUsers & Permissions PluginRolesPublic.
  2. Expand the Service content type under Permissions and tick find and findOne.
  3. Do the same for Incident and Incident Update.
  4. Click Save.

That last content type matters. For a relation to appear in a populated response, the find permission must be granted on the related content type too. If you populate updates but forget to grant find access on Incident Update, the field comes back empty.

Step 5: Seed some sample content

Rather than click through the Admin Panel, seed a few services on first boot. Because the database is only ready during the bootstrap phase, put this in the bootstrap lifecycle function. This is also your first look at the Document Service API, which replaces the old Entity Service in Strapi 5.

Note: Strapi 5 defaults to TypeScript. If you scaffolded with --non-interactive, your project uses TypeScript. The example below uses TypeScript syntax. If you chose JavaScript during setup, convert to CommonJS (module.exports) accordingly.

// status-backend/src/index.ts
export default {
  register() {},

  async bootstrap({ strapi }: { strapi: any }) {
    const existing = await strapi
      .documents('api::service.service')
      .findMany({ status: 'published' });

    if (existing.length === 0) {
      const seed = [
        { name: 'API', status: 'operational' },
        { name: 'Dashboard', status: 'operational' },
        { name: 'CDN', status: 'degraded_performance' },
      ];

      for (const data of seed) {
        await strapi.documents('api::service.service').create({
          data,
          status: 'published',
        });
      }
    }
  },
};

Two details are worth calling out. You access the Document Service through strapi.documents(uid), and you pass status: 'published' to both findMany() and create() so the check looks for existing published services and creates new ones as published. The Document Service defaults to draft, which is the opposite of the REST API default, so being explicit on both operations prevents duplicate seeds and invisible content.

This asymmetry between the two APIs catches people out constantly. The Document Service create() and findMany() default to draft, while the public REST API returns only published entries by default. That means content can look perfectly saved in the Admin Panel yet come back empty from /api/services, because the draft never got published. The fix is to be explicit about status on both paths: pass status: 'published' when you write from the server and when checking for existing records, and remember that a REST read without a status param only ever surfaces published content. Being deliberate on both sides is what keeps content from silently disappearing between the database and the page.

Restart Strapi once more. Confirm the seed worked with a quick curl:

curl "http://localhost:1337/api/services?sort=name:asc"

You should get back three services in the flattened Strapi 5 format, each with a documentId:

{
  "data": [
    {
      "id": 1,
      "documentId": "h90lgohlzfpjf3bvan72mzll",
      "name": "API",
      "status": "operational"
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "pageSize": 25,
      "pageCount": 1,
      "total": 3
    }
  }
}

Notice the shape: name and status sit directly on the object. In Strapi 5 the response is flattened, so there's no more data.attributes nesting from v4.

Step 6: Configure CORS for the frontend

Your Next.js app runs on a different origin (localhost:3000) than Strapi (localhost:1337). If you add client-side fetches later, allow that origin in Strapi. Edit the CORS middleware configuration. Replace the 'strapi::cors' string with a config object.

Note: If you're using TypeScript (the default when creating a Strapi 5 project with --quickstart), use an export default statement in the middlewares configuration file. The example below shows TypeScript syntax:

// status-backend/config/middlewares.ts
export default [
  'strapi::logger',
  'strapi::errors',
  'strapi::security',
  {
    name: 'strapi::cors',
    config: {
      origin: ['http://localhost:3000', 'https://your-production-domain.com'],
      methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
      headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
      keepHeaderOnError: true,
    },
  },
  'strapi::query',
  'strapi::body',
  'strapi::session',
  'strapi::favicon',
  'strapi::public',
];

The strapi::cors middleware wraps @koa/cors, and its configuration options are documented in full. For most status pages, fetching from the server (as we do next) sidesteps browser CORS entirely, but setting this correctly now keeps client-side fetches working if you add them later.

Building the Next.js frontend

Step 1: Create the Next.js app

In a separate terminal, scaffold a fresh Next.js 16 project with TypeScript and the App Router using the create-next-app CLI. If you're new to this stack, the Next.js and Strapi 5 guide covers the fundamentals in more detail.

npx create-next-app@latest status-frontend --ts --app --src-dir --import-alias "@/*"

Once it finishes, move into the directory:

cd status-frontend

Step 2: Set environment variables

Create a .env.local file. The Strapi base URL is safe to expose to the browser, so it gets the NEXT_PUBLIC_ prefix. Any API token stays server-only with no prefix:

# status-frontend/.env.local
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337

Since the public role serves our read endpoints without authentication, no token is needed here. In Next.js, only variables prefixed with NEXT_PUBLIC_ are bundled for the client. Keep that rule in mind: if you later add a token for a protected endpoint, name it STRAPI_API_TOKEN so it never reaches the browser.

Step 3: Define TypeScript types

Model the Strapi 5 response shapes so the rest of your code gets autocomplete and type checking. Every document carries id and documentId, list responses wrap data in an array with pagination meta, and single relations come back as flat objects while many-relations return a { data: [] } wrapper:

// status-frontend/src/lib/types.ts
export interface StrapiDocument {
  id: number;
  documentId: string;
}

export interface StrapiPaginationPage {
  page: number;
  pageSize: number;
  pageCount: number;
  total: number;
}

export interface StrapiMeta {
  pagination?: StrapiPaginationPage;
}

export interface StrapiError {
  status: number;
  name: string;
  message: string;
}

export interface StrapiListResponse<T extends StrapiDocument> {
  data: T[];
  meta: StrapiMeta;
  error?: StrapiError;
}

export type ServiceStatus =
  | 'operational'
  | 'degraded_performance'
  | 'partial_outage'
  | 'major_outage'
  | 'under_maintenance';

export interface Service extends StrapiDocument {
  name: string;
  description: string | null;
  status: ServiceStatus;
  createdAt: string;
  updatedAt: string;
  publishedAt: string | null;
}

export interface IncidentUpdate extends StrapiDocument {
  body: string;
  createdAt: string;
}

export type IncidentStatus =
  | 'investigating'
  | 'identified'
  | 'monitoring'
  | 'resolved';

export interface Incident extends StrapiDocument {
  title: string;
  status: IncidentStatus;
  impact: 'none' | 'minor' | 'major' | 'critical';
  createdAt: string;
  updatedAt: string;
  publishedAt: string | null;
  services?: { data: Service[] };
  updates?: { data: IncidentUpdate[] };
}

Step 4: Write the fetch helpers

Centralize your data fetching in one module. Each helper builds a URL with the right query parameters, sets a revalidation window, and throws on a failed response so errors surface instead of rendering blank sections. For a deeper look at fetch patterns with Strapi, see mastering API requests.

// status-frontend/src/lib/strapi.ts
import type { StrapiListResponse, Service, Incident } from '@/lib/types';

const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';

export async function getServices(): Promise<StrapiListResponse<Service>> {
  const url = `${STRAPI_URL}/api/services?sort=name:asc`;

  const res = await fetch(url, {
    headers: { 'Content-Type': 'application/json' },
    next: { revalidate: 60 },
  });

  if (!res.ok) {
    throw new Error(`Failed to fetch services: ${res.status}`);
  }

  return res.json() as Promise<StrapiListResponse<Service>>;
}

export async function getIncidents(): Promise<StrapiListResponse<Incident>> {
  const url = new URL(`${STRAPI_URL}/api/incidents`);
  url.searchParams.set('sort', 'createdAt:desc');
  url.searchParams.set('populate[services][fields][0]', 'name');
  url.searchParams.set('populate[services][fields][1]', 'status');
  url.searchParams.set('populate[updates][fields][0]', 'body');
  url.searchParams.set('populate[updates][fields][1]', 'createdAt');
  url.searchParams.set('populate[updates][sort][0]', 'createdAt:asc');

  const res = await fetch(url.toString(), {
    headers: { 'Content-Type': 'application/json' },
    next: { revalidate: 60 },
  });

  if (!res.ok) {
    throw new Error(`Failed to fetch incidents: ${res.status}`);
  }

  return res.json() as Promise<StrapiListResponse<Incident>>;
}

The REST API does not populate relations by default, so the incidents query spells out exactly which relation fields to include. The full populate and select syntax is in the REST API docs. Selecting only name, status, body, and createdAt keeps the payload small instead of pulling every field on every related record.

The next: { revalidate: 60 } option tells Next.js to serve a cached response for up to 60 seconds before fetching fresh data. Next.js extends the fetch API so each server request can set its own persistent caching and revalidation behavior. In the current App Router, fetch requests aren't cached by default, so setting revalidation explicitly is what gives this status page a short freshness window without hitting Strapi on every request.

Step 5: Compute the overall status

Statuspage calculates the page's top-level status automatically from the states of all components. Add a small helper that maps service statuses to an overall label and a color. This runs on the server as plain functions, nothing framework-specific:

// status-frontend/src/lib/status.ts
import type { Service, ServiceStatus } from '@/lib/types';

const SEVERITY: Record<ServiceStatus, number> = {
  operational: 0,
  under_maintenance: 1,
  degraded_performance: 2,
  partial_outage: 3,
  major_outage: 4,
};

const LABELS: Record<ServiceStatus, string> = {
  operational: 'Operational',
  under_maintenance: 'Under Maintenance',
  degraded_performance: 'Degraded Performance',
  partial_outage: 'Partial Outage',
  major_outage: 'Major Outage',
};

export function overallStatus(services: Service[]): {
  label: string;
  worst: ServiceStatus;
} {
  if (services.length === 0) {
    return { label: 'No services monitored', worst: 'operational' };
  }

  const worst = services.reduce<ServiceStatus>((acc, service) => {
    return SEVERITY[service.status] > SEVERITY[acc] ? service.status : acc;
  }, 'operational');

  const label =
    worst === 'operational'
      ? 'All Systems Operational'
      : `${LABELS[worst]} on some services`;

  return { label, worst };
}

The severity ordering is what makes the banner behave sensibly. Each status maps to a number, and the overall banner reflects the single worst service state on the board. operational sits at severity 0 because nothing is wrong. under_maintenance ranks low at 1 because planned work is expected and non-alarming, so it shouldn't drown out a real problem elsewhere. The scale climbs through degraded_performance and partial_outage up to major_outage at 4, the highest severity. The reduce call walks every service and keeps the highest severity it has seen so far, which is why a single major_outage service flips the entire banner to red even when everything else reads operational. That matches how a real status page escalates its top-level message: the worst thing happening is the thing users need to know about first.

Step 6: Build the status page

This is a Server Component (the default in the App Router), so it can be async and fetch data directly with no useEffect or client-side loading state. Fetch services and incidents in parallel, compute the banner, and render:

// status-frontend/src/app/page.tsx
import { getServices, getIncidents } from '@/lib/strapi';
import { overallStatus } from '@/lib/status';

export default async function StatusPage() {
  const [servicesRes, incidentsRes] = await Promise.all([
    getServices(),
    getIncidents(),
  ]);

  const services = servicesRes.data;
  const incidents = incidentsRes.data;
  const { label } = overallStatus(services);

  return (
    <main style={{ maxWidth: 720, margin: '0 auto', padding: '2rem' }}>
      <header>
        <h1>{label}</h1>
        <p>Last checked {new Date().toUTCString()}</p>
      </header>

      <section>
        <h2>Services</h2>
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {services.map((service) => (
            <li
              key={service.documentId}
              style={{
                display: 'flex',
                justifyContent: 'space-between',
                padding: '0.75rem 0',
                borderBottom: '1px solid #eee',
              }}
            >
              <span>{service.name}</span>
              <span>{service.status.replace(/_/g, ' ')}</span>
            </li>
          ))}
        </ul>
      </section>

      <section>
        <h2>Recent Incidents</h2>
        {incidents.length === 0 ? (
          <p>No incidents reported.</p>
        ) : (
          incidents.map((incident) => (
            <article key={incident.documentId} style={{ marginBottom: '2rem' }}>
              <h3>{incident.title}</h3>
              <p>
                Status: {incident.status} · Impact: {incident.impact}
              </p>
              {incident.services?.data && incident.services.data.length > 0 && (
                <p>
                  Affected: {incident.services.data.map((s) => s.name).join(', ')}
                </p>
              )}
              {incident.updates?.data && incident.updates.data.length > 0 && (
                <ul>
                  {incident.updates.data.map((update) => (
                    <li key={update.documentId}>
                      <time>{new Date(update.createdAt).toUTCString()}</time>: {update.body}
                    </li>
                  ))}
                </ul>
              )}
            </article>
          ))
        )}
      </section>
    </main>
  );
}

Putting it all together

You have two servers to run. Start Strapi first:

cd status-backend
npm run develop

Then, in your second terminal, start Next.js:

cd status-frontend
npm run dev

Open http://localhost:3000. You should see the banner reading "Degraded Performance on some services" (because the CDN seed was set to degraded_performance), followed by the three seeded services with their states, and an empty incidents section.

To see incidents flow through, go back to the Strapi Admin Panel at http://localhost:1337/admin, open the Content Manager, and create a new Incident. Give it a title like "Elevated API latency," set the status to investigating, link one or more services, add an update, and click Publish. Content in Strapi is saved as a draft first, and the REST API returns only published entries by default, so the publish step is what makes it appear on your page.

Confirm the API sees it before checking the frontend:

curl "http://localhost:1337/api/incidents?populate=*&sort=createdAt:desc"

Wait up to 60 seconds (the revalidation window) or restart the Next.js dev server, then refresh http://localhost:3000. The incident appears under Recent Incidents with its affected services and updates. That round trip, from Admin Panel to published API entry to server-rendered page, is the whole loop your on-call team will use during a real outage. In practice it feels like this: you open the admin, post the first investigating update, then append identified, monitoring, and finally resolved updates as the situation changes. Publishes surface on the public page after the revalidation interval elapses and a subsequent request triggers successful regeneration, so users follow the incident with a short delay rather than in true real time. For a closer look at this caching pattern, see how ISR works with Strapi.

Next steps

  • Add scheduled maintenance. Model planned downtime as a separate content type with start and end times. Model a ScheduledMaintenance collection the same way you built incidents.
  • Trigger instant updates. Instead of waiting for the revalidation window, call revalidatePath('/') from a Next.js Route Handler hit by a Strapi webhook when an incident publishes.
  • Try the official SDK. Swap the hand-written fetch helpers for the typed @strapi/client SDK if you want populate-aware return types.
  • Deploy the stack. Push Strapi to Strapi Cloud with a PostgreSQL database, and deploy the frontend to any Next.js host. For more options, see deployment options for Strapi apps. Remember that content-type models can't be edited in production, so finalize your schema locally first.
  • Show uptime history. Track service state over time and render a rolling uptime percentage per service, the way Statuspage's historical uptime view does.

Paul BratslavskyDeveloper Advocate
Ecosystem·19 min read

How to Build a Social Media Scheduler (Buffer alternative) with Strapi 5

Learn how to build a Buffer-style social media scheduler with Strapi 5 and Next.js 16. Covers cron jobs, Draft & Publish, and typed REST API fetching.

·July 22, 2026
Build a Gift Registry Platform
Ecosystem·20 min read

How to Build a Gift Registry and Wishlist Platform with Next.js 16 and Strapi 5

Learn how to build a gift registry platform with Strapi 5 and Next.js 16. Covers custom controllers, claim logic, and dynamic Open Graph previews.

·June 26, 2026
Build a Mental Health Journal
Ecosystem·23 min read

How to Build a Mental Health Journal and Mood Tracker with Next.js 16 and Strapi 5

Build a private mood tracker and journal with Next.js 16 and Strapi 5. Learn data isolation, mood trend aggregation, and streak tracking.

·June 26, 2026