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",
});
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.

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-After header (seconds) is honored when present; otherwise delay is exponential (baseDelayMs * 2^attempt, capped at maxDelayMs) 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).