Loving Tina? us on GitHub0.0k
v.Latest
Documentation

Visual Editing Setup (Astro)

Loading last updated info...
On This Page

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:

  1. Each editable page renders one or more <script type="application/tina+json"> payloads in <head> (one per query the page consumes).
  2. The bridge, loaded via import { init } from '@tinacms/astro/bridge', 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 a <… data-tina-island="/tina-island/<name>?<params>">. 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), 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' in astro.config.mjs and 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].ts
import 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.ts
import 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.ts
import 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.astro
const { videoId } = Astro.props;
---
<iframe
src={`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.ts
import { 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 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 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 components
YouTubeEmbed,
// Default-tag overrides
p: Paragraph,
h1: Heading1,
blockquote: BlockquoteTag,
code_block: CodeBlock,
a: Anchor,
img: Img,
};

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.

Sub-package exports

Everything you need ships under @tinacms/astro:

Subpath

What it gives you

@tinacms/astro

TinaMarkdown (default export)

@tinacms/astro/bridge

init, refreshForms, tinaField

@tinacms/astro/tina-field

tinaField() only, useful when you don't want to load the bridge bundle

@tinacms/astro/preview

readOverlay(), the server-side helper for the per-island endpoint

@tinacms/astro/types

TypeScript types (TinaRichTextContent, CustomComponentsMap, etc.)

@tinacms/astro/sanitize

sanitizeHref / sanitizeImageSrc for CMS-supplied URLs

See Also

Last Edited: May 8, 2026