Build
Data from APIs
Fetch JSON when the site publishes, keep secrets out of pages, and refresh on a schedule.
fetchJson loads JSON from an API or a CMS when your site publishes. Pages render with what came back, like any other constant, so visitors get finished HTML and no visit waits on your API.
To keep the data current, publish again, refresh the live site on a schedule or when your CMS calls a hook, or let an island fetch the data again in each visitor's browser.
Fetch data when you publish#
Call fetchJson at the top of a file, and export the result to use it elsewhere:
import { fetchJson } from "mockly"; export type Post = { slug: string; title: string; summary: string }; export const posts = fetchJson<Post[]>("https://cms.example.com/api/posts");Any page or component can then read it:
import { For, Page } from "mockly";import { posts } from "../components/posts"; export default function Blog() { return ( <Page title="Blog · Acme"> <main class="mx-auto max-w-2xl space-y-8 p-8"> <For each={posts}> {(post) => ( <article> <h2 class="text-xl font-bold"> <a href={`/blog/${post.slug}`}>{post.title}</a> </h2> <p class="mt-2 text-gray-600">{post.summary}</p> </article> )} </For> </main> </Page> );}- Where: only as a top-level
const, on its own or as the initial value ofstate()(below). Anywhere else is MK6001. - Types:
fetchJson<Post[]>gives the value its type. Mockly doesn't check the data against it, so the type is your promise about what the API sends. - What it holds: the parsed JSON, read-only. Markup, other constants, and islands read it like any constant.
- What reaches the browser: data that only markup reads stays in the HTML. Data an island reads is part of that island's script, so it counts toward the page's JavaScript.
Every publish fetches all the site's data again, in parallel, and sends identical requests once. The studio's preview reuses each response for a minute while you edit.
Request options#
The second argument sets the method, headers, and body:
export const plans = fetchJson<Plan[]>("https://api.example.com/plans", { method: "POST", headers: { Accept: "application/json", "X-Region": "eu" }, body: { currency: "USD" },});| Part | What it takes |
|---|---|
| URL | A string literal: an absolute http:// or https:// URL, without a user name or password, up to 2,048 characters |
method | "GET", the default, or "POST" |
headers | An object whose values are string literals, or secret("NAME") |
body | A JSON literal of up to 64 KB, sent only with "POST", as application/json unless a Content-Type header says otherwise |
Every part is written out in the source, so Mockly knows each request before anything runs. Headers that the request sets itself or that browsers refuse, such as Host, Cookie, Origin, and Content-Length, are MK6001.
A GET request follows up to 5 redirects. A POST request doesn't follow them, and a request with a secret follows them only within its own origin.
Keep secrets out of pages#
An API token goes in a header, as secret():
import { fetchJson, secret } from "mockly"; export const posts = fetchJson<Post[]>("https://cms.example.com/api/posts", { headers: { Authorization: secret("CMS_TOKEN") },});Set its value in the studio: open Data (the database icon at the left of the editor), and add it under Secrets. A saved value can be replaced or deleted, but never shown again. For mockly build, set the environment variable MOCKLY_SECRET_CMS_TOKEN instead.
Each secret also has the addresses it may be sent to, such as cms.example.com. The studio fills them in from where your code sends it. If code sends a secret anywhere else, the request isn't made, and publishing stops with MK6004, so no one who can edit a site's code can send its secrets to a server of their own. Addresses use https, except ones on your own machine, such as http://localhost:4000. Change them from the secret's menu, under Change addresses….
- A secret's value is sent only in the request, and only to its addresses. It never reaches a page, its HTML, or its scripts.
- Names use capital letters, digits, and
_, start with a letter, and are at most 64 characters. Values are 1 to 4,096 printable ASCII characters. secret()works only as a header value offetchJson(MK6001).- If a secret isn't set, publishing stops with MK6003 before anything is sent.
- If a request would send a secret to an address it may not go to, publishing stops with MK6004, and that request isn't made.
Pages from data#
A page whose path has a parameter in brackets, such as pages/blog/[slug].tsx, is published once for each entry of its paths:
import { Page } from "mockly";import { posts } from "../../components/posts"; export const paths = posts.map((post) => post.slug); export default function BlogPost({ slug }: { slug: string }) { const post = posts.find((p) => p.slug === slug); return ( <Page title={`${post?.title ?? "Post"} · Acme`}> <main class="mx-auto max-w-2xl p-8"> <h1 class="text-3xl font-bold">{post?.title}</h1> <p class="mt-4 text-gray-600">{post?.summary}</p> </main> </Page> );}Publishing makes /blog/hello, /blog/second-post, and so on, each with its entry as props. A path that isn't an entry gets the not-found page, and publishing again picks up new entries. In the preview, a picker switches between the route's pages.
- Entries: with one parameter, each entry is a string or a whole number. With several, as in
pages/[year]/[slug].tsx, each is an object with a string for each parameter:{ year: "2026", slug: "hello" }. - Values are 1 to 100 letters, digits,
-,_, or~, and a route has at most 1,000 pages. A value that breaks these rules, an entry listed twice, or a page that another page already is stops the publish with MK1017. pathsis exported by every page with a parameter, and only by those (MK1016).
See Pages and routes for how files become routes.
Refresh the live site#
Data is as fresh as the last publish. To fetch it again without publishing what's in the editor, open Data in the editor's sidebar. The card at the top says when the live site's data was last fetched, or why the last refresh failed. You can:
- Refresh now, once, from that card.
- Refresh on a schedule, under Refresh: every 15 minutes, every hour, every 6 hours, or every day, counted from the last time data was fetched. A publish counts.
- Make a hook URL for your CMS to call when content changes, under CMS hook.
A refresh republishes the live version, from the source it was published from, with freshly fetched data. Unpublished edits stay in the editor. When the new data changes nothing, nothing is recorded. When it does, the refresh becomes a new version in the site's history, marked as republished with fresh data (see Publishing and history). If someone publishes while a refresh runs, the refresh is dropped.
The hook URL#
Your CMS sends a POST request to the hook URL when its content changes. The body is ignored, so any CMS's webhook works. The hook answers once the refresh is done:
{ "ok": true, "changed": true, "hash": "3f9a0c2e7b1d4a56" }changed is false when nothing changed. A refresh that fails answers 422, with "ok": false, a message, and the diagnostics. A URL that's been replaced or removed answers 404. Calls that arrive while a refresh runs share one more refresh after it, so a burst of calls costs at most two publishes, and the last one has every change. A site's hook takes 30 calls in 10 minutes; more get 429.
When a fetch fails#
Each request must answer with a 2xx status and JSON within 10 seconds, at most 5 MB in size and nested at most 64 levels deep. Otherwise:
- Publishing stops with MK6002 at the
fetchJson()call, saying what went wrong, such as the status the API answered. The live site stays as it was: pages are never published without their data. - A refresh leaves the live site as it is. Data shows why the last refresh failed, the hook answers 422, and a schedule tries again at its next interval.
Refresh in the browser#
For data that changes between publishes, such as stock or prices, make it state with refresh. Each visitor's browser then fetches it again:
import { fetchJson, state } from "mockly"; export const stock = state(fetchJson<{ left: number }>("https://api.example.com/stock"), { refresh: 60000 }); export function StockLeft() { return <p class="text-sm text-gray-600">{stock.value.left} left in stock</p>;}The state starts with the data fetched when you published, so the HTML has it. Then the browser fetches it when the page loads, and every refresh milliseconds while the page is visible. { refresh: true } fetches it once, when the page loads. Without refresh, state(fetchJson(…)) is shared state that starts with the fetched data, and only your handlers change it.
- Shared state: every island on the page that reads it gets the new data. See Interactive parts.
- Interval: 1,000 to 86,400,000 milliseconds, one second to one day. Only one request is out at a time.
- No secrets: every visitor's browser would see them (MK6001). It can't be remembered, either.
- CORS: the browser sends the request without cookies, so the API must allow your site's origin with
Access-Control-Allow-Origin. The page's Content-Security-Policy allows exactly the origins of these requests, so a redirect to another origin is blocked. - Failures are ignored: a response that fails, takes more than 10 seconds, or isn't the same kind of JSON value as the data (an object for an object, a list for a list) leaves the page as it is.
Limits#
| Limit | Value |
|---|---|
| Response | 2xx and JSON, within 10 seconds, at most 5 MB, nested at most 64 levels, with no NUL characters |
| Redirects | Up to 5 for GET, within the same origin for a request with a secret, none for POST |
| Request body | A JSON literal of up to 64 KB |
| Pages per route | 1,000 |
| Secret names | Capital letters, digits, and _, starting with a letter, up to 64 characters |
| Secret addresses | Up to 10 origins per secret, https except on your own machine |
| Refresh schedule | Every 15 minutes, hour, 6 hours, or day |
| Hook calls | 30 per site in 10 minutes |
| Refresh in the browser | Every 1,000 to 86,400,000 milliseconds, or once |
Every visitor gets the same data: Mockly doesn't fetch anything when a page is requested, or for a particular visitor.