← All guides

Mock a REST API for your Astro site in 60 seconds

You ran npm create astro, wrote the page, and now you need data. Astro's twist: by default your data code runs at build time, so "mock the API" also means "have a URL that's up when CI builds your site". The choice is: hard-code an array, maintain a folder of JSON fixtures, or point your frontmatter fetch at a real hosted API with realistic data β€” one whose failure modes you can trigger on demand. This guide does the third one.

Every snippet below was run before publishing, in a fresh npm create astro project (Astro 7.1.6, TypeScript) β€” astro build output inspected (36 pages), the client island typed into in a real browser, the on-demand page curl-checked, astro check clean (0 errors, 0 warnings). The three gotchas below come from that run, not from docs.

1. Get an API (10 seconds)

Click "try it" on the Mockbird landing page, or from a terminal:

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -d '{"name":"astro-demo","preset":"ecommerce"}'
# β†’ {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123"}

The ecommerce preset seeds products, orders, customers and reviews with realistic data β€” names, prices, ratings, timestamps. No signup, no config, CORS on by default. Put the base URL in .env:

# .env
PUBLIC_API_URL=https://mockbird.mockbird.workers.dev/m/abc123

The PUBLIC_ prefix matters: it's what lets the same variable reach both frontmatter (build-time) and client-side <script> tags (section 5). (No terminal handy? Every example below also works against the public demo project: https://mockbird.mockbird.workers.dev/m/demo.)

2. Generate the TypeScript types (don't write them)

curl -o src/types.ts https://mockbird.mockbird.workers.dev/m/abc123/types.ts

Interfaces for every resource, generated from the live schema. ?format=zod gets you Zod schemas instead if you validate at the boundary.

3. Fetch in frontmatter β€” it runs at build time

Astro frontmatter supports top-level await, and in a static build it executes exactly once, during astro build:

---
// src/pages/index.astro
import type { Product } from "../types";

const API = import.meta.env.PUBLIC_API_URL;
const res = await fetch(`${API}/products?sortBy=rating&order=desc&limit=6`);
if (!res.ok) throw new Error(`API ${res.status} on /products β€” check PUBLIC_API_URL`);
const products: Product[] = await res.json();
---
<html lang="en">
  <head><meta charset="utf-8" /></head>
  <body>
    <h1>Top rated</h1>
    <ul>
      {products.map((p) => (
        <li>
          <a href={`/products/${p.id}/`}>{p.name}</a> β€” ${p.price} Β· ⭐ {p.rating}
        </li>
      ))}
    </ul>
  </body>
</html>

Two deliberate details. The throw means an unhealthy API fails the build loudly instead of shipping an empty page β€” we pointed the fetch at &mock_status=503 and the build stopped with [ERROR] API 503 on /products, exactly where you want to find out. And gotcha #1, which we hit for real: Astro only injects <meta charset> when your page has a <head>. Write a bare-bones <html><body> demo page without one and realistic data will mojibake in the browser β€” our em dashes rendered as Ò€” and the ⭐ became three garbage glyphs until we added the head above. Hard-coded ASCII fixtures never catch this; data with real punctuation does.

4. A static page per record: getStaticPaths

---
// src/pages/products/[id].astro
import type { Product } from "../../types";

export async function getStaticPaths() {
  const API = import.meta.env.PUBLIC_API_URL;
  const res = await fetch(`${API}/products?limit=100`);
  const products: Product[] = await res.json();
  return products.map((p) => ({
    params: { id: String(p.id) },   // ← must be a string, see below
    props: { product: p },
  }));
}

const { product } = Astro.props;
---
<html lang="en">
  <head><meta charset="utf-8" /></head>
  <body>
    <h1>{product.name}</h1>
    <img src={product.image} alt={product.name} width="320" />
    <p>{product.description}</p>
    <p>${product.price} Β· {product.category} Β· {product.inStock ? "in stock" : "out of stock"}</p>
  </body>
</html>

One build, 30 seeded products, 30 static pages β€” we counted them in dist/. Passing the record through props means each page renders without a second fetch. Gotcha #2: the API returns numeric ids, and Astro route params must be strings β€” skip the String() and the build dies with [GetStaticPathsInvalidRouteParam] Expected a string or undefined, received `number`. We reproduced it so you don't have to.

5. Real pagination with paginate()

---
// src/pages/products/page/[page].astro
import type { Page, GetStaticPaths } from "astro";
import type { Product } from "../../../types";

export const getStaticPaths = (async ({ paginate }) => {
  const API = import.meta.env.PUBLIC_API_URL;
  const res = await fetch(`${API}/products?limit=100`);
  const products: Product[] = await res.json();
  return paginate(products, { pageSize: 10 });
}) satisfies GetStaticPaths;

const page = Astro.props.page as Page<Product>;
---
<html lang="en">
  <head><meta charset="utf-8" /></head>
  <body>
    <h1>Products β€” page {page.currentPage} of {page.lastPage}</h1>
    <ul>
      {page.data.map((p) => <li>{p.name} β€” ${p.price}</li>)}
    </ul>
    {page.url.prev && <a href={page.url.prev}>← Prev</a>}
    {page.url.next && <a href={page.url.next}>Next β†’</a>}
  </body>
</html>

30 records at pageSize: 10 built /products/page/1 through /3, prev/next links correct at both ends β€” because the dataset is big enough for pagination to be real. That's the quiet advantage of seeded data over the three-item array you'd have typed by hand.

6. "But static data goes stale" β€” rebuild picks up writes

The mock is a real database: POST a record and it persists. We added a product with curl, ran astro build again, and dist/products/31/index.html existed with the new name β€” no code changes, the getStaticPaths fetch simply saw 31 records. Your content workflow while the backend doesn't exist yet is: edit data in the dashboard (or curl), rebuild. And if you want builds to be reproducible instead of live, save a snapshot and pin every build-time request to it with ?mock_snapshot=name β€” frozen fixtures when you want them, live data when you don't.

7. A client island: live search in a plain <script>

Static pages, dynamic behavior β€” Astro's whole pitch. A <script> tag is processed by the bundler, so imports and import.meta.env work; gotcha #3 is that only PUBLIC_-prefixed vars are inlined into client code. Forget the prefix and the browser sees undefined β€” the build won't warn you.

---
// src/pages/search.astro β€” the page itself is still static
---
<html lang="en">
  <head><meta charset="utf-8" /></head>
  <body>
    <h1>Live search</h1>
    <input id="q" placeholder="Search products…" autofocus />
    <ul id="results"></ul>

    <script>
      const API = import.meta.env.PUBLIC_API_URL;
      const input = document.getElementById("q") as HTMLInputElement;
      const list = document.getElementById("results")!;
      let timer: ReturnType<typeof setTimeout>;

      input.addEventListener("input", () => {
        clearTimeout(timer);
        timer = setTimeout(async () => {
          const res = await fetch(`${API}/products?q=${encodeURIComponent(input.value)}&limit=5`);
          const items = await res.json();
          list.innerHTML = items
            .map((p: { name: string; price: number }) => `<li>${p.name} β€” $${p.price}</li>`)
            .join("");
        }, 200);
      });
    </script>
  </body>
</html>

We typed "cloud" into this in a real browser and got 5 live full-text matches β€” the mock's ?q= searches every field, CORS already on. This is also where you rehearse loading and error states: append &mock_delay=2000 to see your spinner actually spin, or &mock_chaos=0.3 to make a third of requests fail while you write the retry branch β€” a whole guide of its own.

8. On-demand rendering: fresh data per request

When a page genuinely can't be prerendered (a dashboard, anything personalized), add an adapter and opt that one page out:

npx astro add node   # installs @astrojs/node, updates astro.config.mjs
---
// src/pages/orders.astro
export const prerender = false;   // rendered on every request

import type { Order } from "../types";

const API = import.meta.env.PUBLIC_API_URL;
const res = await fetch(`${API}/orders?sortBy=placedAt&order=desc&limit=5`);
if (!res.ok) {
  Astro.response.status = 502;
}
const orders: Order[] = res.ok ? await res.json() : [];
---
<html lang="en">
  <head><meta charset="utf-8" /></head>
  <body>
    <h1>Recent orders</h1>
    {!res.ok && <p class="error">Upstream API returned {res.status}. Try again.</p>}
    <ul>
      {orders.map((o) => <li>{o.orderNumber} β€” ${o.total} Β· {o.status}</li>)}
    </ul>
  </body>
</html>

Same frontmatter code β€” it just runs per request now. We ran node dist/server/entry.mjs and curled it: fresh orders on every hit, static pages still served alongside. The part a hand-rolled fixture can't do: append ?mock_status=503 to the upstream URL and your error branch renders for real β€” our page returned HTTP 502 with the "try again" copy, no code changes, any status 200–599.

What about JSON fixtures or content collections?

Both are good tools; here's the honest split.

Imported JSON files (import products from "../data/products.json") are the zero-dependency move and fully offline. But you're now hand-writing the data (so it's three tidy ASCII items β€” see gotcha #1), there's no pagination/filtering/search to exercise, nothing persists, error and latency states can't be rehearsed at all, and the teammate building the companion mobile app gets nothing.

Content collections are Astro's excellent answer for content β€” markdown, frontmatter, type-safe queries. They're not an API: no CRUD, no query params, nothing exists at a URL your client island, Postman, or CI-against-a-preview can hit. Use them for the blog; mock the API for the app.

Imported JSONContent collectionsMockbird
Build-time fetch + getStaticPathsβœ” (import)βœ” (getCollection)βœ” plain fetch
Client islands can query itβ€”β€”βœ” same URL, CORS on
Pagination / filters / searchYou write themFilter in JSBuilt in
Writes persistβ€”β€”βœ” real database
Delay / error / chaos injectionβ€”β€”?mock_delay / ?mock_status / ?mock_chaos
Where it winsZero deps, offlineMarkdown content, type-safeβ€”

9. Ship day: swap the env var

Mockbird follows plain REST conventions, so moving to the real backend is changing PUBLIC_API_URL. Nothing else changes. If the backend team wants a contract to build against, hand them the mock's live spec β€” https://mockbird.mockbird.workers.dev/m/abc123/openapi.json β€” or the generated Postman collection.

The same flow works for React, Vue, Next.js, Nuxt, SvelteKit, Angular, Solid and htmx. Free tier: 20 projects, 10k requests/project/day, no signup. Docs β†’
⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, reviews…) in the dashboard β€” real URL, data browser already open, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.