Do you keep your images and content self-contained in your TinaCMS + Next.js project?


When building a website using TinaCMS and Next.js, not all images need to live next to your content. Shared assets like logos, icons, or other global visuals are best stored in a central media folder (e.g. /public/uploads). This keeps things simple and avoids duplication.

However, for documents that embed images—like blog posts or rules like this one—it’s important to keep the content (Markdown/MDX files) and related media together in the same folder. This self-contained structure improves maintainability, makes GitHub editing clearer, and supports portability.

Video: Tina.io - The 3 options for storing markdown in GitHub for TinaCMS (5 min)

By default, Tina stores content in a /content folder and images in /public, which breaks self-containment and can cause confusion.

Let's explore three solutions to fix that and store content and images together.

You have 3 options:

1. Default structure + matching folders

  • Content in /content
  • Images in /public
  • Add a link to image folder in frontmatter of content file

✅ Pros

  • Works out of the box

❌ Cons

  • Not self-contained
  • Prone to errors when renaming/moving files
  • You must manually manage matching folder names and use frontmatter to point to images.

2. Everything inside content folder

  • Each document gets a folder in /content
  • Images are stored alongside the MDX file

option 2 structure
Figure: Option 2 - Folder structure - rules example

✅ Pros

  • Fully self-contained
  • Tina Media Manager works

❌ Cons

  • Requires extra setup: update config, collections, and add a middleware

Example implementation (Rules)

import { NextResponse } from 'next/server';

export function middleware(req) {
  if (process.env.DEVELOPMENT !== 'true') {
    return NextResponse.next();
  }

  const url = req.nextUrl;

  // Check if the request is for an image in the content/rules folder
  if (url.pathname.startsWith('/rules/') && url.pathname.match(/\.(png|jpg|jpeg|gif|webp)$/i)) {
    const escapedUrl = encodeURIComponent(url.pathname);
    const apiUrl = `http://localhost:3000/api/serve-image?filepath=${escapedUrl}`;
    console.log('Redirecting to API for image:', apiUrl);
    return NextResponse.redirect(apiUrl);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/rules/:path*'],
};

Figure: Middleware to intercept media requests and call internal API

import { NextApiRequest, NextApiResponse } from 'next';
import fs from 'fs';
import path from 'path';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (process.env.DEVELOPMENT !== 'true') {
    res.status(403).send('API route is disabled in production');
    return;
  }

  const { filepath } = req.query;

  if (!filepath || typeof filepath !== 'string') {
    res.status(400).send('Filepath is required');
    return;
  }

  const unescapedFilepath = decodeURIComponent(filepath);
  const imagePath = path.join(process.cwd(), 'content/', unescapedFilepath);

  try {
    const imageBuffer = fs.readFileSync(imagePath);
    const contentType = `image/${path.extname(unescapedFilepath).slice(1)}`;

    res.setHeader('Content-Type', contentType);
    res.send(imageBuffer);
  } catch (error) {
    console.error('Error reading image:', error);
    res.status(404).send('Image not found');
  }
}

Figure: Internal API to serve images from content folder

You can find more details on this repository

  • Each document has a folder in /public/uploads
  • Images and MDX file live together

option 3 structure
Figure: Option 3 - Folder structure - rules example

✅ Pros

  • Fully self-contained
  • Tina Media Manager works
  • No custom middleware needed

❌ Cons

  • MDX files live in public, which is unconventional—but works

This option is clean, simple, and works with Tina’s Media Manager out of the box — no special setup required.

Example Collection config (Rules)

import { Collection } from "tinacms";

const Rule: Collection = {
  label: "Rules",
  name: "rule",
  path: "public/uploads/rules",
  fields: [
    ...
  ],
  ...
};

export default Rule;

Figure: Path pointing to public/uploads folder

See more on Tina.io - Storing Media With Content.


We open source.Loving SSW Rules? Star us on GitHub. Star
Stand by... we're migrating this site to TinaCMS