Migrating Astro to React-free Visual Editing
Older Astro sites integrated TinaCMS visual editing through @astrojs/react and a custom client:tina directive — every editable page hydrated a React component just for the editor. That requirement is gone: @tinacms/astro runs the same visual-editing UX through a vanilla-JS bridge.
This page walks an existing site through the migration. The end state matches the Astro Starter Template; diff against it for any step that's unclear.
What changes
Before | After | |
|---|---|---|
Deps |
|
|
Output |
|
|
Page wiring |
|
|
Custom MDX | React components in | One schema |
Rich-text rendering |
|
|
1. Update dependencies
Remove the React deps, install the new packages:
npm uninstall @astrojs/react react react-dom react-icons @tanstack/react-virtualnpm install @tinacms/astro @astrojs/node
2. Switch to SSR
In astro.config.mjs:
import { defineConfig } from 'astro/config';import mdx from '@astrojs/mdx';import node from '@astrojs/node';export default defineConfig({output: 'server',adapter: node({ mode: 'standalone' }),integrations: [mdx()],});
Drop the react() integration. If you used a custom client directive (addClientDirective({ name: 'tina', … })), drop that too — it's replaced by the data-tina-island mechanism.
3. Delete the React glue
The React-based path keeps editing components in tina/pages/. Those are no longer referenced:
rm -rf tina/pages tina/components/IconComponent.tsx
If you scaffolded from the old starter you'll also have an astro-tina-directive/ folder at the repo root. Delete it.
4. Add the lib helpers
Create src/lib/ with the helpers that bridge Astro and Tina:
metadata.ts—addContentSourceMetadata()andhashFromQuery()tina-preview.ts—withOverlay()(the disk-fetch-vs-overlay seam)queries.ts— extracts query strings fromtina/__generated__/data.ts— per-collection fetchers (getPage,getBlog, etc.)islands.ts— registry of editable regions
Copy these from the starter template. Each file is small (under 100 lines) and self-contained.
5. Add the per-island endpoint
Create src/pages/tina-island/[name].ts — single dynamic route the bridge POSTs to. Copy from the starter.
6. Move rendering from React to Astro
For each tina/pages/*.tsx file, create a matching .astro component under src/components/islands/:
- // tina/pages/HomePage.tsx- import { useTina } from 'tinacms/dist/react';- import { TinaMarkdown } from 'tinacms/dist/rich-text';- export default function HomePage(props) {- const { data } = useTina(props);- return <main><TinaMarkdown content={data.page.body} /></main>;- }+ ---+ // src/components/islands/PageBody.astro+ import TinaMarkdown from '@tinacms/astro';+ import { tinaField } from '@tinacms/astro/tina-field';+ const { data } = Astro.props;+ ---+ {data?.body && (+ <div data-tina-field={tinaField(data, 'body')}>+ <TinaMarkdown content={data.body} />+ </div>+ )}
The render shape is the same. The only difference: no useTina() — the bridge handles re-rendering by re-fetching this island.
7. Update each page
Pages move from "render a React component with client:tina" to "fetch from Tina at request time and wrap in data-tina-island":
- ---- import HomePage from '../../tina/pages/HomePage.tsx';- import client from '../../tina/__generated__/client';- const data = await client.queries.page({ relativePath: 'home.mdx' });- ---- <html>- <body>- <HomePage {...data} client:tina />- </body>- </html>+ ---+ export const prerender = false;+ import PageBody from '../components/islands/PageBody.astro';+ import { getPage } from '../lib/data';+ const slug = 'home';+ const page = await getPage(slug, Astro.request);+ ---+ <html>+ <body>+ <main data-tina-island={`/tina-island/page?slug=${slug}`}>+ <PageBody data={page.data?.page} />+ </main>+ </body>+ </html>
8. Wire the bridge in your base layout
Add to your shared <head> partial:
---const forms = Astro.props.forms ?? [];---{forms.map((form) => (<script type="application/tina+json" set:html={JSON.stringify(form)} />))}<script>import { init, refreshForms } from '@tinacms/astro/bridge';init();document.addEventListener('astro:page-load', refreshForms);</script>
Pass the form payload from each page through the layout's forms prop. The form's id, query, variables, and data come straight off the getPage() (or getBlog(), etc.) result.
9. Convert custom MDX components
Each React MDX component in tina/pages/*.tsx (passed via <TinaMarkdown components={…} />) becomes:
- A
Template(schema-only) insrc/components/mdx/<Name>.template.ts - A renderer in
src/components/mdx/<Name>.astro
See Visual Editing Setup → Astro → Custom MDX embeds for the full pattern.
10. Verify
Run pnpm dev, open /admin, and edit a page. You should see:
- Field changes appear in the public preview as you type.
- Clicking a
data-tina-field-marked element focuses the matching field in the sidebar. - Navigating between docs in the admin (e.g.
#/~/→#/~/about) updates the sidebar to match the active page.
If the third bullet doesn't work, double-check the astro:page-load listener in step 8 — without it, view-transitions setups strand the admin on the previous page's form.
See Also
- Astro Setup Guide — the full integration starting point
- Visual Editing Setup → Astro — bridge architecture deep-dive
- Astro Starter Template — reference implementation