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.
By default, Tina stores content in a /content folder and images in /public, which breaks self-containment and can cause confusion.
You have 3 options:
/content/public✅ Pros
❌ Cons
/contentFigure: Option 2 - Folder structure - rules example
✅ Pros
❌ Cons
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 folderif (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
/public/uploadsFigure: Option 3 - Folder structure - rules example
✅ Pros
❌ Cons
public, which is unconventional—but worksThis option is clean, simple, and works with Tina’s Media Manager out of the box — no special setup required.
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.