Media
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:
| Backend | Status |
|---|---|
| Cloudinary | Available |
| AWS S3 Bucket | Coming soon |
| DigitalOcean Spaces | Coming 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:
# 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>
| Var | Read by | Why this format |
|---|---|---|
NUXT_TINA_* | Nitro server runtime | Nuxt's own env prefix convention |
TINA_TOKEN + TINA_PUBLIC_CLIENT_ID | tinacms CLI | The CLI has its own naming convention |
NEXT_PUBLIC_TINA_CLIENT_ID | next-tinacms-cloudinary internals | This 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.