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 + a ~2 kB gzipped postMessage bridge that loads only inside the editor iframe.
The flow:
- Each editable page renders one or more
<script type="application/tina+json">payloads in<head>(one per query the page consumes). - The bridge, loaded via
import { init } from '@tinacms/astro/bridge', reads those payloads, postsopento the parent admin window, and seeds an in-memory data store. - As the editor types, the admin posts
updateDataback to the iframe. The bridge stores it. - Each editable region in the page is wrapped in a
<… data-tina-island="/tina-island/<name>?<params>">. On every store update, the bridge POSTs the current overlay to that endpoint. - 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), init() exits immediately. The bridge ships ~2 kB gzipped of dead code that never runs for non-admin visitors.
Server-rendered Astro is required. The per-island refresh endpoint runs on every keystroke and needs a server runtime. Set
output: 'server'inastro.config.mjsand install an adapter (@astrojs/node,@astrojs/vercel,@astrojs/netlify, etc.).
Install
npm install @tinacms/astro @astrojs/node
Wire the bridge
Inside your base layout's <head>:
---const forms = [{ id: page.id, query: page.query, variables: page.variables, data: page.data },];---<head>{forms.map((form) => (<script type="application/tina+json" set:html={JSON.stringify(form)} />))}<script>import { init, refreshForms } from '@tinacms/astro/bridge';init();// Required if you use Astro's <ClientRouter /> or any view-transitions setup:// re-scans form payloads after each soft navigation. No-op without view transitions.document.addEventListener('astro:page-load', refreshForms);</script></head>
The forms array carries one entry per Tina query the page renders. The admin uses these to render the sidebar form for each editable doc.
Mark editable regions
Wrap each editable region in a data-tina-island element. The attribute value is the URL of the per-island refresh endpoint, the same path the bridge will POST to:
---import PageBody from '../components/islands/PageBody.astro';import { getPage } from '../lib/data';const slug = 'home';const page = await getPage(slug, Astro.request);---<main data-tina-island={`/tina-island/page?slug=${slug}`}><PageBody data={page.data?.page} /></main>
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';import { tinaField } from '@tinacms/astro/tina-field';const { data } = Astro.props;---<h1 data-tina-field={tinaField(data, 'title')}>{data.title}</h1><div data-tina-field={tinaField(data, 'body')}><TinaMarkdown content={data.body} /></div>
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.
The per-island endpoint
A single dynamic Astro route handles every island the bridge calls. It looks up the island in a registry, fetches the latest data, and renders the matching component:
// src/pages/tina-island/[name].tsimport type { APIRoute } from 'astro';import { experimental_AstroContainer as AstroContainer } from 'astro/container';import { islands } from '../../lib/islands';export const prerender = false;export const ALL: APIRoute = async ({ params, request, url }) => {const island = islands[params.name ?? ''];if (!island) return new Response('Unknown island', { status: 404 });const data = await island.fetch(request, url.searchParams);const container = await AstroContainer.create();const html = await container.renderToString(island.component, {props: island.propsFromData(data, url.searchParams),});const { tag, className } = island.wrapper;const cls = className ? ` class="${className}"` : '';const marker = `${url.pathname}${url.search}`;return new Response(`<${tag}${cls} data-tina-island="${marker}">${html}</${tag}>`,{ headers: { 'Content-Type': 'text/html; charset=utf-8' } });};
The registry (src/lib/islands.ts) maps island names to { fetch, component, wrapper, propsFromData }. Adding a new editable region only ever touches the registry, never the dynamic route.
The withOverlay() data seam
The per-route data loader runs in two contexts: production (no admin → real fetch from disk) and edit mode (admin overlay → use the bridge's POST body). withOverlay() lets the same code path serve both:
// src/lib/data.tsimport client from '../../tina/__generated__/client';import { withOverlay } from './tina-preview';export function getPage(slug: string, request: Request) {const variables = { relativePath: `${slug}.mdx` };return withOverlay({query: PAGE_QUERY,variables,request,fetcher: async () => (await client.queries.page(variables))?.data,defaults: { page: { seoTitle: '', body: null } },});}
The returned object's id is a stable hash of { query, variables }; the bridge uses the same hashing client-side to match overlays back to forms.
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:
// src/components/mdx/YouTubeEmbed.template.tsimport type { Template } from 'tinacms';export const youTubeEmbedTemplate: Template = {name: 'YouTubeEmbed',label: 'YouTube Embed',fields: [{ name: 'videoId', label: 'YouTube video ID', type: 'string', required: true },],};
2. The Astro renderer:
---// src/components/mdx/YouTubeEmbed.astroconst { videoId } = Astro.props;---<iframesrc={`https://www.youtube.com/embed/${videoId}`}title="YouTube video player"allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture"allowfullscreen/>
Register the template on the rich-text field's templates array:
// tina/collections/page.tsimport { youTubeEmbedTemplate } from '../../src/components/mdx/YouTubeEmbed.template';export const PageCollection = {// ...fields: [{name: 'body',type: 'rich-text',isBody: true,templates: [youTubeEmbedTemplate],},],};
And register the renderer on the <TinaMarkdown components={…}> map:
---import TinaMarkdown from '@tinacms/astro';import YouTubeEmbed from '../mdx/YouTubeEmbed.astro';const { data } = Astro.props;const components = { YouTubeEmbed };---<TinaMarkdown content={data.body} components={components} />
The twonamestrings must match. The template'sname: 'YouTubeEmbed'and the components-map keyYouTubeEmbedare how the renderer dispatchesmdxJsxFlowElementnodes from the rich-text AST. A mismatch silently renders the embed as raw HTML.
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:
const components = {// Custom MDX componentsYouTubeEmbed,// Default-tag overridesp: Paragraph,h1: Heading1,blockquote: BlockquoteTag,code_block: CodeBlock,a: Anchor,img: Img,};
Supported override keys: p, h1–h6, ul, ol, li, blockquote, lic, a, img, code_block, hr, break. See the @tinacms/astro README for the full node reference.
Sub-package exports
Everything you need ships under @tinacms/astro:
Subpath | What it gives you |
|---|---|
|
|
|
|
|
|
|
|
| TypeScript types ( |
|
|
See Also
- Astro Starter Template: reference implementation with all of the above wired up
- The Click-To-Edit API:
tinaField()semantics - Visual Editing Router: wiring deep-link admin URLs to your routes