SDK
Public client
Read published content with the browser-safe client from @myna-sh/sdk.
The root export of @myna-sh/sdk is the public content client: a thin, typed wrapper over the published-content API described in reading published content. It is safe in browsers and on servers — it uses ordinary fetch, supports AbortSignal, throws typed errors, and automatically retries safe reads. Draft content, schema, and every mutation live in the server-only management client.
Install
npm install @myna-sh/sdk
The package is ESM with three subpath exports: . (this client), ./management (server-only management client), and ./schema (schema definition helpers). There is no separate management package.
Create a client
import { createMyna } from "@myna-sh/sdk";
const myna = createMyna({ project: "my-site" });
createMyna(options) returns a MynaClient. Options:
| Option | Type | Default | Description |
|---|---|---|---|
project |
string |
— (required) | Project id or slug. |
apiKey |
string |
— | Public content API key (myna_sk_...) for private collections. Optional for public reads. |
apiUrl |
string |
https://api.myna.sh |
API origin. The client appends /v1. |
assetsUrl |
string |
https://api.myna.sh/assets |
Asset delivery base URL, used by images.url and images.srcSet. Set it only if you run a dedicated asset host. |
fetch |
FetchLike |
global fetch |
Custom fetch implementation. |
previewToken |
string |
— | Default preview token applied to every read unless overridden per call. |
previewMode |
"overlay" | "scoped" |
"overlay" |
How a preview token composes with published content. See preview tokens. |
retry |
RetryOptions |
{ maxRetries: 3 (1 in a browser), baseDelayMs: 200, maxDelayMs: 5000, maxNetworkRetries: 1 (0 in a browser) } |
Retry policy for safe reads. See retry behavior. |
If no global fetch exists and none is provided, the constructor's first request throws (No fetch implementation available).
Reading content
The client exposes three read surfaces:
| Method | Returns | Purpose |
|---|---|---|
collections.list(opts?) |
PublishedCollectionContract[] |
Published collections (key, displayName, kind, pathTemplate). |
entries.list(collection, opts?) |
{ data, nextCursor } |
Cursor-paginated published entries. |
entries.get(collection, slugOrId, opts?) |
PublishedEntryContract |
Single entry by slug or id. |
singleton(collection, opts?) |
PublishedEntryContract |
The single entry of a singleton collection. |
Every entry has the shape { id, collection, slug, fields, meta: { revision, publishedAt } }. Pass a generated MynaCollections registry to createMyna and fields is typed, collection keys are checked, and order, filter, and include accept only the fields that support them:
import { createMyna } from "@myna-sh/sdk";
import type { MynaCollections } from "./myna.generated";
const myna = createMyna<MynaCollections>({ project: "my-site" });
const { data, nextCursor } = await myna.entries.list("posts", {
order: "-publishedAt",
limit: 20,
});
data[0].fields.title; // string
const post = await myna.entries.get("posts", "hello-world");
const settings = await myna.singleton("site-settings");
Without a type parameter, fields is Record<string, unknown> and collection keys are unchecked strings.
entries.list returns { data, nextCursor }; nextCursor is null on the last page:
let cursor: string | undefined;
do {
const page = await myna.entries.list("posts", { limit: 100, cursor });
render(page.data);
cursor = page.nextCursor ?? undefined;
} while (cursor);
List options
entries.list accepts the full HTTP query vocabulary; entries.get and singleton accept the projection subset (fields, include, representation, previewToken, signal).
| Option | Type | Maps to |
|---|---|---|
order |
string |
order=field ascending, order=-field descending. |
limit |
number |
limit= page size. |
cursor |
string |
cursor= from a previous nextCursor. |
q |
string |
q= full-text search across every text field of an entry. |
locale |
string |
locale= resolves localized fields to one locale, with default-locale fallback. |
filter |
Record<string, unknown> |
filter[field]=value and nested filter[field][op]=value. |
fields |
string[] |
fields=a,b,c projection (arrays serialize comma-joined). |
include |
string[] |
include=ref — resolve references up to depth one. |
representation |
"source" | "rendered" |
representation=rendered returns sanitized rendered HTML for markdown fields. |
previewToken |
string |
Reads through the preview composition (below). |
previewMode |
"overlay" | "scoped" |
Overrides the client's preview mode for this call. |
signal |
AbortSignal |
Aborts the request. |
Filters mirror the HTTP shape exactly — a plain value is equality, an object nests operators:
await myna.entries.list("posts", {
filter: {
category: "engineering", // filter[category]=engineering
publishedAt: { gte: "2026-01-01" }, // filter[publishedAt][gte]=2026-01-01
},
fields: ["title", "slug", "excerpt"],
include: ["author"],
representation: "rendered",
order: "-publishedAt",
});
Search
const results = await myna.entries.list("posts", { q: "webhook signatures" });
q searches every text value in an entry at any depth — no field configuration. The syntax is PostgreSQL's websearch_to_tsquery: bare words are ANDed, "quoted phrases" match in order, - excludes. It composes with filter, order, and pagination.
Backlinks
const mentions = await myna.entries.referencedBy("posts", "hello-world");
// [{ id: "ent_…", collection: "posts", slug: "a-later-post" }]
The inverse of a reference field: published entries in public collections whose current revision points at this one. Capped at 200.
Images
Asset fields hold an asset id. images turns one into a delivery URL sized for where it is about to be rendered:
<img
src={myna.images.url(post.fields.cover, { width: 1200 })}
srcSet={myna.images.srcSet(post.fields.cover, [640, 1200, 1920])}
sizes="(max-width: 768px) 100vw, 1200px"
alt=""
/>
| Option | Type | Meaning |
|---|---|---|
width |
number |
Target width in CSS pixels, snapped up to a supported width. |
format |
"auto" | "webp" | "avif" | "jpeg" | "png" |
auto (the default when a transform is requested) negotiates from the browser's Accept header. |
quality |
number |
30–95, defaulting to 80. |
previewToken |
string |
For assets that are not published yet; falls back to the client's default token. |
With no options the URL is the original file. IMAGE_WIDTHS is exported if you need the ladder itself.
Reading at a release
release pins every read to a numbered release, so a build is reproducible: the same commit and the same pin produce the same site.
import { createMyna } from "@myna-sh/sdk";
import { readLock } from "@myna-sh/sdk/lock";
const lock = readLock(); // reads myna.lock, or undefined
const myna = createMyna({
project: "my-site",
...(lock ? { release: lock.release } : {}),
});
A per-call release overrides the client default, and null opts one read back out to live:
await myna.entries.list("posts", { release: 41 }); // an older release
await myna.entries.list("posts", { release: null }); // live, ignoring the pin
@myna-sh/sdk/lock is a separate Node-only entry point — importing it cannot pull node:fs into a browser bundle — and exposes readLock, findLockfile, parseLock, formatLock, and writeLock. A missing lockfile is an ordinary state and reads live; a lockfile that exists but does not parse throws, because a build that believes it is pinned and is not is the failure this exists to prevent.
release and previewToken cannot be combined: one reads a release, the other reads a draft, and the constructor rejects a client configured with both. See pinning content to a release for the lockfile and the pull-request workflow around it.
Preview tokens
A preview token (minted via the management client's previews.create) lets the same read code render draft content from a change set. Set it once as a client default, or per call:
// Default for every read — e.g. in a preview deployment.
const preview = createMyna({ project: "my-site", previewToken: token });
// Or per call, overriding the client default.
await myna.entries.get("posts", "hello-world", { previewToken: token });
With a token in effect, reads return the collection as it would be published: entries the change set updates are replaced, entries it creates appear, and entries it unpublishes or deletes are absent. Everything the change set does not touch is served as published.
order, filter, limit, and cursor apply to that composition, so a previewed list paginates and sorts exactly as the published one does.
Two modes are available through previewMode:
| Mode | Reads return |
|---|---|
overlay (default) |
The full collection, composed as above |
scoped |
Only the entries the change set touches, with nextCursor: null |
const preview = createMyna({ project: "my-site", previewToken: token }); // overlay
const diff = createMyna({ project: "my-site", previewToken: token, previewMode: "scoped" });
Choose scoped for a diff or review interface. For a site preview, overlay is what a page rendering a list needs — scoped would show the change set rather than the site.
In scoped mode, requesting an entry the change set does not touch throws a plain Error rather than a MynaApiError: the token resolved, but the entry is not part of the snapshot.
Browser safety
The public client is designed to ship to browsers. Public content API keys are read-only credentials scoped to published content, so exposing one in client-side code is acceptable when you need private-collection reads. Never put a management token in a browser or in code shipped to one — management tokens can mutate schema, entries, and billing. Anything that requires @myna-sh/sdk/management belongs on a server; see authentication for the credential taxonomy.
AbortSignal and custom fetch
Every read accepts a signal, which also cancels retry backoff waits:
const controller = new AbortController();
const pending = myna.entries.list("posts", { signal: controller.signal });
controller.abort(); // rejects with the abort reason
Pass fetch to inject a custom implementation — undici dispatchers, framework caching wrappers, or test doubles:
const myna = createMyna({
project: "my-site",
fetch: (input, init) => fetch(input, { ...init, cache: "no-store" }),
});
Request coalescing
Identical concurrent GETs share a single request, and each caller receives its own readable response.
Only in-flight requests are shared — this is coalescing, not caching, so a read issued after the first completes goes to the network again. Requests passing a signal are excluded, so one caller's abort cannot cancel another's.
Retry behavior
All public-client requests are GETs, so all are eligible for retry:
- Retried statuses: 408, 425, 429, 500, 502, 503, 504. Aborts are never retried.
- A
Retry-Afterheader (seconds) is honored when present; otherwise delay is exponential (baseDelayMs * 2^attempt, capped atmaxDelayMs) with 50–100% jitter.
Transport failures — fetch rejecting rather than returning a response — have a separate budget, with lower defaults in the browser:
| Option | Server default | Browser default |
|---|---|---|
maxRetries |
3 | 1 |
maxNetworkRetries |
1 | 0 |
In the browser a rejected fetch is most often a blocked CORS response, a disallowed origin, or a cancelled request, none of which succeed on a retry. They surface as an opaque TypeError, identical to a dropped connection.
Repeated network errors in a browser usually mean an origin problem rather than a retry problem — check with myna cors check --origin <your-origin>.
Set either budget at construction: retry: { maxRetries: 0, maxNetworkRetries: 0 }.
Caching
Reads return a strong ETag with Cache-Control: public, max-age=0, must-revalidate, so browsers and CDNs can keep a copy and revalidate it with a conditional request. The client itself holds no cache beyond in-flight coalescing. For stale-while-revalidate in a React app, use @myna-sh/react; otherwise pair it with your framework's data layer.
Error handling
Non-2xx responses (after retries) throw MynaApiError, which carries the parsed RFC 9457 problem document — the same shape documented in error codes. Use the isMynaApiError guard:
import { isMynaApiError } from "@myna-sh/sdk";
try {
await myna.entries.get("posts", "missing-slug");
} catch (error) {
if (isMynaApiError(error)) {
// error.status — HTTP status
// error.code — stable machine code, e.g. "NOT_FOUND"
// error.title / error.detail — human-facing text
// error.requestId — quote this in support requests
// error.fields — field-level validation errors, when present
// error.problem — the full problem document
if (error.status === 404) return null;
}
throw error;
}
MynaApiError.is(error) is an equivalent static guard. Responses that are not problem documents are synthesized into one with code REQUEST_FAILED (4xx) or INTERNAL (5xx).
