nuxt-tina

TinaCMS integration module for Nuxt with contextual editing and preview mode
329
Install
npm install nuxt-tina
Latest Version:0.1.2
Vue:3
License:MIT
Last Updated:Sep 18, 2026
Links: GitHub  ·  npm
Maintainer: narr07

Nuxt Tina

npm version npm downloads License Nuxt

TinaCMS integration module for Nuxt, with contextual visual editing and preview mode.

Features

  • ✏️  Contextual visual editing bridge (two-way postMessage sync with Tina Admin)
  • 🧩  Auto-imported useTina, tinaField, useTinaBridge, useTinaPreview composables
  • 🖼️  TinaProvider and TinaMarkdown components (Comark-style AST rendering)
  • 🚀  Zero-config: scaffolds a starter tina/config.ts on first run
  • ☁️  Optional Cloudinary media store, opt-in via media.provider

Quick Setup

Install the module to your Nuxt application with one command:

npx nuxt module add nuxt-tina

That's it! You can now use Nuxt Tina in your Nuxt app ✨

Configuration

All options are optional — set clientId/token/branch via env vars (recommended, so nothing sensitive lands in nuxt.config.ts) or override any default here:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-tina'],

  tina: {
    // Tina Cloud credentials — defaults read NUXT_TINA_CLIENT_ID / NUXT_TINA_TOKEN
    // (or TINA_CLIENT_ID / TINA_TOKEN) from the environment.
    clientId: '',
    token: '',
    branch: 'main',

    // Where tina/config.ts lives, relative to the project root.
    tinaPath: 'tina',

    // Contextual/visual editing bridge (iframe postMessage sync with Tina Admin).
    visualEditing: true,

    // Media store — leave as 'tina' for the built-in git-based store, or
    // 'cloudinary' to wire up the /api/cloudinary/** route (see nuxt-tina/media).
    media: {
      publicFolder: 'public',
      mediaRoot: 'uploads',
      provider: 'tina',
    },

    enabled: true,
  },
})

Environment variables

You really only have two secrets — a Tina Cloud client ID and a token. They show up under several different variable names below because three separate tools read the environment independently, each with its own naming convention. This is duplication by necessity, not a mistake — set all the ones your setup needs.

# .env

# 1. This module (Nuxt/Nitro runtime) — populates the tina.clientId/token/branch
# defaults shown above. Bare TINA_CLIENT_ID / TINA_TOKEN also work as a fallback.
NUXT_TINA_CLIENT_ID=
NUXT_TINA_TOKEN=
NUXT_TINA_BRANCH=main

# 2. The `tinacms` CLI itself (tinacms dev / tinacms build) — generates
# tina/__generated__ and talks to Tina Cloud independently of this module.
# Same values as above, but the CLI has its own variable names — note
# TINA_PUBLIC_CLIENT_ID here, not TINA_CLIENT_ID.
TINA_PUBLIC_CLIENT_ID=
TINA_TOKEN=

# 3. next-tinacms-cloudinary — only needed with media.provider: 'cloudinary'.
# It's a Next.js-oriented package that hardcodes Next's own NEXT_PUBLIC_ env
# convention internally, so it needs the client ID under this name too.
NEXT_PUBLIC_TINA_CLIENT_ID=

# This module's own /api/cloudinary/** route (also only with media.provider: 'cloudinary').
NUXT_TINA_CLOUDINARY_CLOUD_NAME=
NUXT_TINA_CLOUDINARY_API_KEY=
NUXT_TINA_CLOUDINARY_API_SECRET=

Mermaid diagrams (optional)

TinaMarkdown doesn't render Mermaid diagrams by default — mermaid is a large dependency, so it's opt-in. To enable it:

npm install mermaid
<TinaMarkdown :content="post.body" :components="{ 'tina-mermaid': TinaMermaid }" />

TinaMermaid is auto-imported by the module, so no explicit import is needed. Without this prop, ```mermaid code blocks render as plain code instead.

Nuxt Content compatibility (optional)

You don't need Nuxt Content at all. useTina, TinaMarkdown, TinaProvider, and the visual editing bridge all read directly from Tina's own generated GraphQL client and have zero dependency on @nuxt/content — it's not installed by this module, and nothing breaks without it. Everything below is purely for people who also want to query the same content through Nuxt Content (search, listings, queryCollection()) alongside Tina's editing:

npm install @nuxt/content

The two systems don't talk to each other automatically, and they don't share one config file — you keep writing fields in tina/config.ts as always, and content.config.ts derives from it. Fields only ever get written once, in tina/config.ts — tinaFieldsToZodShape() only derives Zod from Tina fields, there's no helper for the reverse direction, so tina/config.ts is the one place field definitions belong.

Folder structure — both configs must point at the same markdown files. Tina's path is relative to the project root and includes content/; Nuxt Content's source is relative to its own content directory (content/ by default) and does not repeat that prefix:

my-app/
├── content/
│   └── posts/
│       └── hello-world.md     # the same file, read by both systems
├── tina/
│   └── config.ts              # path: 'content/posts' — field definitions live here
├── content.config.ts          # source: 'posts/*.md'  — derives its schema from tina/config.ts
└── nuxt.config.ts

Starting point — a post collection with fields defined once, in tina/config.ts:

// tina/config.ts
export default defineConfig({
  // ...
  schema: {
    collections: [
      {
        name: 'post',
        label: 'Posts',
        path: 'content/posts',
        format: 'md',
        fields: [
          { type: 'string', name: 'title', label: 'Title', isTitle: true, required: true },
          { type: 'string', name: 'description', label: 'Description' },
          { type: 'datetime', name: 'date', label: 'Date' },
          { type: 'rich-text', name: 'body', label: 'Body', isBody: true },
        ],
      },
    ],
  },
})

What goes in content.config.ts: nothing but the derive call — no field lives here directly. For each Tina collection you also want in Nuxt Content, look up its fields and pass them through tinaFieldsToZodShape:

// content.config.ts
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
import { tinaFieldsToZodShape } from 'nuxt-tina/utils'
import tinaConfig from './tina/config'

const postFields = tinaConfig.schema.collections.find(c => c.name === 'post')!.fields

export default defineContentConfig({
  collections: {
    posts: defineCollection({
      type: 'page',
      source: 'posts/*.md',
      schema: z.object(tinaFieldsToZodShape(postFields, z)),
    }),
  },
})

Fields marked isBody: true are skipped — Nuxt Content parses the markdown body itself, it isn't a frontmatter field. If one field needs stricter validation than the derived type (e.g. an email format), override it after the spread instead of writing the whole schema by hand:

schema: z.object({
  ...tinaFieldsToZodShape(postFields, z),
  email: z.string().email(),
})

This is a one-way, build-time helper, not a runtime sync — it saves you from maintaining the same field list twice, nothing more.

Contribution

Local development
# Install dependencies
npm install

# Generate type stubs
npm run dev:prepare

# Develop with the playground
npm run dev

# Build the playground
npm run dev:build

# Run ESLint
npm run lint

# Run Vitest
npm run test
npm run test:watch

# Release new version
npm run release

Version History

Version Released Vue License
0.1.2 ^3.0 MIT
0.1.1 ^3.0 MIT