# 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](/content-api/reading-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](/sdk/management-client). ## Install ```bash 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 ```ts 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](#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](#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](/schema/generated-types) `MynaCollections` registry to `createMyna` and `fields` is typed, collection keys are checked, and `order`, `filter`, and `include` accept only the fields that support them: ```ts import { createMyna } from "@myna-sh/sdk"; import type { MynaCollections } from "./myna.generated"; const myna = createMyna({ 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` and collection keys are unchecked strings. `entries.list` returns `{ data, nextCursor }`; `nextCursor` is `null` on the last page: ```ts 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` | `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: ```ts 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 ```ts 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 ```ts 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: ```tsx ``` | Option | Type | Meaning | | --- | --- | --- | | `width` | `number` | Target width in CSS pixels, snapped up to a [supported width](/concepts/assets#image-transformations). | | `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](/concepts/releases), so a build is reproducible: the same commit and the same pin produce the same site. ```ts 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: ```ts 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](/concepts/pinned-content) 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](/concepts/change-sets). Set it once as a client default, or per call: ```ts // 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` | ```ts 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](/getting-started/authentication) for the credential taxonomy. ## AbortSignal and custom fetch Every read accepts a `signal`, which also cancels retry backoff waits: ```ts 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: ```ts const myna = createMyna({ project: "my-site", fetch: (input, init) => fetch(input, { ...init, cache: "no-store" }), }); ``` ## Request coalescing Identical concurrent `GET`s 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 `GET`s, 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 `. 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`](/sdk/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](/management-api/errors). Use the `isMynaApiError` guard: ```ts 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).