Guide

Media

The built-in git-based media store, and backend media handlers for external storage.

Default: git-based

With no extra configuration, files uploaded through an image field are committed straight into the repo, under media.tina in tina/config.ts:

media: {
  tina: {
    mediaRoot: 'uploads',
    publicFolder: 'public',
  },
}

Simplest option, no extra infrastructure, but every upload becomes a Git commit.

Backend media handlers

For external storage, nuxt-tina wires up backend media handlers one at a time. Cloudinary is the one that's actually implemented today. The others are on the roadmap, not yet available:

BackendStatus
CloudinaryAvailable
AWS S3 BucketComing soon
DigitalOcean SpacesComing soon

media.provider only accepts 'tina' \| 'cloudinary' right now: trying to set anything else is a type error, not a silently-ignored option.

Cloudinary

nuxt-tina ships a ready-to-use Cloudinary adapter via next-tinacms-cloudinary (not a hard dependency: only dynamically imported when you actually use it).

1. Enable the server route

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-tina'],
  tina: {
    media: {
      provider: 'cloudinary', // default: 'tina'
    },
  },
})

This registers /api/cloudinary/** (a Nitro adapter for next-tinacms-cloudinary's createMediaHandler), only when enabled, so if you don't use Cloudinary, that package never gets bundled at all.

2. Set up credentials

Cloudinary needs three different sets of env vars. This isn't duplication you can trim. Each is read by a different consumer:

.env
# Read by the Nitro runtime (the /api/cloudinary/** handler)
NUXT_TINA_CLOUDINARY_CLOUD_NAME=<cloud-name>
NUXT_TINA_CLOUDINARY_API_KEY=<api-key>
NUXT_TINA_CLOUDINARY_API_SECRET=<api-secret>

# Read by the tinacms CLI (tinacms build / tinacms dev)
TINA_TOKEN=<same-value-as-NUXT_TINA_TOKEN>
TINA_PUBLIC_CLIENT_ID=<same-value-as-NUXT_TINA_CLIENT_ID>

# Read by next-tinacms-cloudinary (this package comes from the Next.js ecosystem)
NEXT_PUBLIC_TINA_CLIENT_ID=<same-value-as-NUXT_TINA_CLIENT_ID>
VarRead byWhy this format
NUXT_TINA_*Nitro server runtimeNuxt's own env prefix convention
TINA_TOKEN + TINA_PUBLIC_CLIENT_IDtinacms CLIThe CLI has its own naming convention
NEXT_PUBLIC_TINA_CLIENT_IDnext-tinacms-cloudinary internalsThis package hardcodes reading env vars with a NEXT_PUBLIC_ prefix

3. Install the dependencies

pnpm add next-tinacms-cloudinary @tinacms/auth

4. Point tina/config.ts at Cloudinary

import { tinaCloudinaryMediaStore } from 'nuxt-tina/media'

export default defineConfig({
  media: {
    loadCustomStore: tinaCloudinaryMediaStore,
  },
})

⚠️ Gotcha: don't use process.env here to toggle on/off

The media section of tina/config.ts also gets bundled into the JavaScript that runs in the admin browser (not just executed on the server): @tinacms/cli copies this file's raw source for the parts that need to run client-side (loadCustomStore, a custom ui.component, etc.), not the result of Node evaluating it.

That means if you write:

// ❌ WRONG: process.env is always undefined in the browser
const useCloudinary = !!process.env.NUXT_TINA_CLOUDINARY_CLOUD_NAME
media: useCloudinary ? { loadCustomStore: tinaCloudinaryMediaStore } : { tina: {...} }

...the toggle always falls back to the default in the admin (the Media Manager browses the project filesystem, not Cloudinary), even though the credentials are valid and the server route works fine when tested directly with curl. Check tina/__generated__/config.prebuild.jsx if you want to see the proof: the process.env.X expression sits there verbatim, never inlined to true/false.

The fix: if you want to flip Cloudinary on or off, change the literal value directly in the source:

// ✅ CORRECT: a fixed literal, not an env expression
const useCloudinary = true // change this by hand, don't read an env var

The server-side toggle (media.provider in nuxt.config.ts) isn't affected by this: that one is purely Node/build-time and works fine with a normal env var.

Copyright © 2026