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

Visual Editing Setup (Astro)

Loading last updated info...

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.

<WarningCallout
  body={<>
    **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

```bash
npm install @tinacms/astro @astrojs/node
```

## Wire the bridge

Inside your base layout's `<head>`:

```astro
---
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 — same path the bridge will POST to:

```astro
---
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:

```astro
---
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](/docs/contextual-editing/tinafield/) 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:

```ts
// 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 → `{ 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:

```ts
// 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`), you author **two files**:

1. A schema `Template` describing the editor UI:

   ```ts
   // 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, named the same as the template:

   ```astro
   ---
   // 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:

```ts
// 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:

```astro
---
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:

```ts
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`, `h1`–`h6`, `ul`, `ol`, `li`, `blockquote`, `lic`, `a`, `img`, `code_block`, `hr`, `break`. See the [`@tinacms/astro` README](https://github.com/tinacms/tinacms/blob/main/packages/@tinacms/astro/README.md) 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()` — 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

* [Astro Starter Template](https://github.com/tinacms/tina-astro-starter) — reference implementation with all of the above wired up
* [The Click-To-Edit API](/docs/contextual-editing/tinafield/) — `tinaField()` semantics
* [Visual Editing Router](/docs/contextual-editing/router/) — wiring deep-link admin URLs to your routes
* [Migrating from React-based Visual Editing](/docs/migrations/astro-react-free-visual-editing/)
Last Edited: May 8, 2026