Visual Editing Setup (Astro)

Astro's visual editing path doesn't use useTina(); that hook is React-specific. Instead, Astro sites use @tinacms/astro, a vanilla-Astro renderer plus a small postMessage bridge that loads only inside the editor iframe.

The flow:

  1. The tina() integration's request-scoped middleware buffers each HTML response and, on edit-mode requests, splices the bridge bootstrap and one <div data-tina-form> payload per TinaCMS query the page consumes into <head>.
  2. The bridge (loaded from /admin/bridge.js) reads those payloads, posts open to the parent admin window, and seeds an in-memory data store.
  3. As the editor types, the admin posts updateData back to the iframe. The bridge stores it.
  4. Each editable region in the page is wrapped in <TinaIsland>, which emits a <… data-tina-island="/tina-island/<name>?<params>"> marker. On every store update, the bridge POSTs the current overlay to that endpoint.
  5. The endpoint re-renders the matching Astro component against the overlay data and returns an HTML fragment. The bridge swaps it into the live DOM.

In production (no admin parent), the middleware injects nothing and init() exits immediately — production HTML is byte-identical to a TinaCMS-free Astro app. (Exception: pages that use <TinaIsland> carry a one-line inline bootstrap so editing still works when the page is statically built.)

You need an SSR adapter. The per-island refresh endpoint (/tina-island/[name]) runs at request time on every keystroke. Set adapter in astro.config.mjs to @astrojs/node, @astrojs/vercel, @astrojs/netlify, or @astrojs/cloudflare. output: 'server' is the simplest choice; output: 'static' also works as long as you wrap editable regions in <TinaIsland />.

Install

If your collections use MDX bodies, add @astrojs/mdx. If you're self-hosting (no TinaCloud), add @tinacms/datalayer.

Wire the integration

Add tina() to astro.config.mjs once. That single call wires the middleware (which resolves Astro.locals.tinaEdit and injects the bridge wiring on edit-mode responses) and stages the vanilla-JS bridge as a static asset at /admin/bridge.js.

tinaAdminDevRedirect() is a dev-only Vite plugin that redirects /admin and /admin/ to /admin/index.html so the admin SPA is reachable from a bare URL during astro dev.

There is no shared <head> wiring component, no forms prop to thread, no manual init() call. The integration handles all of it.

Data loaders — wrap every query with requestWithMetadata

Each route's data loader calls the generated TinaCMS client and pipes the result through requestWithMetadata(). That single call:

  • Hashes { query, variables } into a stable form id the bridge uses to address the form.
  • Reads the bridge's overlay from request-scoped storage and swaps data for the unsaved overlay when the page is rendered inside the admin iframe.
  • Stamps the result with the metadata tinaField() needs for click-to-focus.
  • Records the form payload that the middleware will splice into <head> for edit-mode requests.

priority: 'primary' marks a form as the page's main document so the editor opens it on load instead of landing on a layout-level global (a header/footer config, say). It mirrors useTina()'s experimental___selectFormByFormId. On SSR pages the first requestWithMetadata() call is treated as primary automatically; pass it explicitly when you want to override.

The returned object has { data, query, variables, id }data is what you render, and the hashed id is what the bridge uses to match overlays back to forms.

The island registry

A small registry maps each editable region to a fetcher, a component, an outer wrapper, and a propsFromData projection. Adding a new editable region is one entry here; the dynamic route picks it up automatically.

The per-island endpoint — one generic route

The bridge POSTs to /tina-island/<name> on every keystroke. One dynamic route, plus experimental_createIslandRoute(), handles every entry in the registry:

That's the only file the bridge needs. The helper enforces same-origin POST with the TinaCMS-preview content-type, renders the registered component via Astro's container API, and wraps the output in the registered wrapper element — matching the page-side <TinaIsland> so the bridge can swap it in.

Use editable regions in pages

Wrap each editable region in <TinaIsland>. The wrapper prop must match the registry entry's wrapper (the bridge swaps the whole element). Mark the page's main region primary so the admin opens that form on load.

Add field-level click-to-edit

tinaField() returns a string identifying which form field a DOM element corresponds to. Stamp it on any element you want clickable in the editor:

Import TinaMarkdown from @tinacms/astro/TinaMarkdown.astro (the subpath), not from the bare @tinacms/astro. Astro's type-checker reads the .astro file directly via the subpath; the bare-package default resolves through the types condition to a placeholder that Astro doesn't recognize as a renderable component.

Coarse-grained markers (the whole body) are usually right; clicking any rich-text node inside focuses the editor on that field. See The Click-To-Edit API for the full helper reference.

Custom MDX embeds

To render a custom component inside a rich-text body (for example, a YouTubeEmbed), author two files: a schema Template describing the editor UI, and an Astro renderer named the same as the template.

1. The schema Template:

2. The Astro renderer:

Register the template on the rich-text field's templates array:

And register the renderer on the <TinaMarkdown components={…}> map:

The two name strings must match. The template's name: 'YouTubeEmbed' and the components-map key YouTubeEmbed are how the renderer dispatches mdxJsxFlowElement nodes from the rich-text AST. A mismatch renders the embed as a visible placeholder so you spot missing registrations during development.

Default-tag overrides

The same components map can override the default HTML tag for any rich-text node, useful for styling without forking the renderer:

Supported override keys: p, h1h6, ul, ol, li, blockquote, lic, a, img, code_block, hr, break. See the @tinacms/astro README for the full node reference.

CMS-supplied URLs in a and img nodes pass through sanitizeHref / sanitizeImageSrc, blocking javascript:, data:, vbscript:, and protocol-relative URLs. Both helpers are exposed on @tinacms/astro/sanitize for use in your own components.

Static-site editing

output: 'static' is supported. The middleware described above only runs on on-demand-rendered routes, so on a prerendered page it never injects anything — instead, <TinaIsland> emits a tiny in-iframe bootstrap script that loads /admin/bridge.js only when the page is open inside the admin iframe. On boot the bridge fetches each island's /tina-island/[name] endpoint (still prerender = false, so the adapter renders it on demand) to pick up the page's form payloads, after which editing works exactly as in an SSR project.

Requirements for static editing:

  • Wrap every editable region in <TinaIsland> with a registered island — that's both how the bridge re-renders regions and how the bootstrap gets onto the page.
  • Pass primary on the page's main <TinaIsland>. On a static page the bridge can't tell which island is "the page" automatically, so without this the editor may land on the multi-document picker.
  • Keep export const prerender = false on src/pages/tina-island/[name].ts.

Trade-off: a page that uses <TinaIsland> ships that one-line inline bootstrap in production HTML — it's no longer byte-identical to a TinaCMS-free Astro app. Pages without <TinaIsland> are unchanged.

Cross-origin admin

If your admin is on a different origin (Codespaces, separate-domain self-hosted), set in your .env:

Comma-separate to allow multiple (preview + prod). The middleware embeds it inline so the bridge validates inbound postMessages.

Sub-package exports

Everything you need ships under @tinacms/astro:

Subpath

What it gives you

@tinacms/astro

requestWithMetadata, tinaField, QueryResult, and the rich-text types

@tinacms/astro/TinaMarkdown.astro

<TinaMarkdown content components /> — the rich-text renderer. Import from this subpath so Astro's check sees a real .astro component.

@tinacms/astro/TinaIsland.astro

<TinaIsland name wrapper params [primary] /> — marker wrapper for an editable region

@tinacms/astro/integration

tina() integration — auto-wires the middleware and stages /admin/bridge.js

@tinacms/astro/middleware

The middleware the integration auto-wires — exported in case you need to compose it manually

@tinacms/astro/data

requestWithMetadata, QueryResult

@tinacms/astro/tina-field

tinaField() helper for data-tina-field markers

@tinacms/astro/experimental

experimental_createIslandRoute(), IslandRegistry, IslandConfig

@tinacms/astro/is-edit-mode

isEditMode(request) — server-side admin-iframe detection

@tinacms/astro/types

TinaRichTextContent, CustomComponentsMap, TinaRichTextNode, MdxElement, TextElement

@tinacms/astro/sanitize

sanitizeHref / sanitizeImageSrc for CMS-supplied URLs

@tinacms/astro/bridge

init, refreshForms, and the rest of @tinacms/bridge

@tinacms/astro/vite

tinaAdminDevRedirect() — dev-only redirect from /admin to /admin/index.html

See Also

Last Edited: May 26, 2026