34.3 Tina CMS: Visual Editing with GitHub Backend
Right, so you’ve built a Hugo site. It’s fast, it’s clean, and you feel like a wizard every time you run hugo server. But then you have to edit a config.toml file or, heaven forbid, a Markdown file directly to change the hero text from “We create synergies” to something that doesn’t make your readers vomit a little in their mouths. This is where a headless CMS swoops in, and TinaCMS is a particularly intriguing option because it doesn’t just manage your content—it lets you see the changes live, right on your site, as if it were a WordPress page. It’s visual editing without sacrificing the static-site goodness.
The core idea is brilliantly simple, though the setup can feel a bit like wizardry. Tina acts as a layer on top of your development server. When you’re in edit mode, it intercepts the content queries, pulls the data from your chosen backend (in our case, GitHub), and overlays nifty little editable blocks right onto your page. You make a change, it writes that change back to the correct file in your GitHub repository as a commit, which then triggers a rebuild (probably on Vercel or Netlify). It’s a fantastic developer/content collaborator handshake.
The Core Setup: More Than Just an NPM Install
First, you can’t just npm install your way out of this. You need to wrap your entire site configuration in a Tina-centric structure. This means creating a tina/config.{js,ts} file. This file is the beating heart of your Tina setup—it defines the content models (called “collections”) and, crucially, tells Tina how to read from and write to GitHub.
Here’s a barebones example for a simple blog. Note the clientId and branch—you’ll get the clientId from the Tina cloud dashboard, and the branch is, well, your branch. Protecting your main branch? You’ll be working on a tina or preview branch.
// tina/config.ts
import { defineConfig } from "tinacms";
export default defineConfig({
clientId: process.env.NEXT_PUBLIC_TINA_CLIENT_ID, // Never hardcode this
branch: process.env.NEXT_PUBLIC_TINA_BRANCH || "main",
token: process.env.TINA_TOKEN, // Write access token from GitHub
build: {
outputFolder: "admin",
publicFolder: "public",
},
media: {
tina: {
mediaRoot: "uploads",
publicFolder: "static",
},
},
schema: {
collections: [
{
name: "post",
label: "Posts",
path: "content/posts",
fields: [
{
type: "string",
name: "title",
label: "Title",
isTitle: true,
required: true,
},
{
type: "rich-text",
name: "body",
label: "Body",
isBody: true,
},
],
},
],
},
});
The Content API: Where the Magic (and Complexity) Happens
Hugo is stubborn. It likes its content in specific folders and its data in specific formats. Tina doesn’t naturally speak “Hugo’s specific flavor of front matter.” This is the biggest “gotcha.” To bridge this gap, you must use Tina’s Content API, which involves creating a special [...routes].{js,ts} file. This file acts as a translator, fetching data via Tina’s GraphQL layer and formatting it exactly as Hugo expects it.
This is where you’ll do the dirty work of converting Tina’s JSON output into TOML or YAML front matter and Markdown body content. It’s finicky, and if you get the formatting wrong, Hugo will either throw a fit or, worse, silently ignore your content. The example below is a simplified version. You’ll need to tailor this heavily to match your front matter schema.
// pages/[[...routes]].js
import { staticRequest } from "tinacms";
import { TinaMarkdown } from "tinacms/dist/rich-text";
const getStaticProps = async (ctx) => {
const query = `{
post(relativePath: "my-first-post.md") {
title
body
}
}`;
const variables = { relativePath: "my-first-post.md" };
const data = await staticRequest({ query, variables });
// This is the crucial translation step for Hugo
const hugoFormattedContent = `+++
title = "${data.post.title}"
+++
${TinaMarkdown({ content: data.post.body })}`;
return {
props: {
data,
hugoFormattedContent, // You'd then pass this to your page component
},
};
};
The Authentication Tango with GitHub
For Tina to write back to your repo, you need to give it permission. This involves creating a GitHub OAuth App, which is about as much fun as doing your taxes, but slightly more rewarding. You’ll get a clientId and a clientSecret. The clientId goes in your Tina config; the clientSecret is stored safely on Tina’s cloud servers. You, the user, will then authenticate through GitHub when you enter edit mode, granting Tina permission to commit on your behalf. The common pitfall here is getting the callback URL wrong in your GitHub OAuth App settings—it must be exactly what Tina Cloud expects, or the whole dance falls apart.
Is This Actually a Good Idea?
Let’s be direct: this setup is complex. You’re introducing a Node.js server (for the Content API) and a GraphQL layer on top of a Go-based static site generator that prizes simplicity. The development experience is fantastic—seeing edits live is a game-changer for content teams. But you’re adding a significant layer of abstraction and a new point of failure.
The best practice? This is perfect for smaller marketing sites or blogs where non-technical editors need that visual context. For a massive site with thousands of pages, the abstraction might become a performance and maintenance burden. It’s a trade-off: unparalleled editing experience for increased architectural complexity. Choose wisely.