Tina CMS is an open-source, Git-backed headless CMS that provides visual editing for Markdown, MDX, and JSON content. Unlike database-backed CMSs, Tina stores all content in your Git repository, giving you version history, branch-based workflows, and the ability to work offline. This guide covers self-hosting Tina CMS on your VPS.
Why Tina CMS?
- Git-backed: Content stored as files in your repository — full version history
- Visual editing: WYSIWYG editor overlaid on your actual website
- Type-safe: Schema-defined content with TypeScript support
- Framework agnostic: Works with Next.js, Astro, Hugo, and more
- Self-hostable: Run your own Tina backend instead of using Tina Cloud
Set Up Tina with Next.js
# Add Tina to an existing Next.js project
cd /opt/my-website
npx @tinacms/cli@latest init
# This creates:
# tina/config.ts — Tina schema configuration
# tina/__generated__/ — Generated types and queries
# Install dependencies
npm install tinacms @tinacms/cli
Define Your Content Schema
// tina/config.ts
import { defineConfig } from 'tinacms'
export default defineConfig({
branch: process.env.TINA_BRANCH || 'main',
clientId: process.env.TINA_CLIENT_ID || '',
token: process.env.TINA_TOKEN || '',
build: {
outputFolder: 'admin',
publicFolder: 'public',
},
media: {
tina: {
mediaRoot: 'uploads',
publicFolder: 'public',
},
},
schema: {
collections: [
{
name: 'post',
label: 'Blog Posts',
path: 'content/posts',
format: 'mdx',
fields: [
{
type: 'string',
name: 'title',
label: 'Title',
isTitle: true,
required: true,
},
{
type: 'datetime',
name: 'publishedAt',
label: 'Published Date',
required: true,
},
{
type: 'string',
name: 'excerpt',
label: 'Excerpt',
ui: { component: 'textarea' },
},
{
type: 'image',
name: 'heroImage',
label: 'Hero Image',
},
{
type: 'string',
name: 'tags',
label: 'Tags',
list: true,
},
{
type: 'rich-text',
name: 'body',
label: 'Body',
isBody: true,
templates: [
{
name: 'Callout',
label: 'Callout Box',
fields: [
{ name: 'type', label: 'Type', type: 'string',
options: ['info', 'warning', 'tip'] },
{ name: 'text', label: 'Text', type: 'string' },
],
},
],
},
],
},
{
name: 'page',
label: 'Pages',
path: 'content/pages',
format: 'mdx',
fields: [
{ type: 'string', name: 'title', label: 'Title', isTitle: true, required: true },
{ type: 'rich-text', name: 'body', label: 'Body', isBody: true },
],
},
],
},
})
Self-Hosted Tina Backend
# Instead of Tina Cloud, run your own backend
# tina/database.ts
import { createDatabase, createLocalDatabase } from '@tinacms/datalayer'
import { GitHubProvider } from 'tinacms-gitprovider-github'
export default createDatabase({
gitProvider: new GitHubProvider({
repo: process.env.GITHUB_REPO!,
owner: process.env.GITHUB_OWNER!,
token: process.env.GITHUB_TOKEN!,
branch: process.env.GITHUB_BRANCH || 'main',
}),
// For local development without GitHub:
// Use createLocalDatabase() instead
})
# Environment variables
cat >> .env ({
slug: edge?.node?._sys.filename,
})) || []
}
export default async function PostPage({
params,
}: { params: { slug: string } }) {
const { data } = await client.queries.post({
relativePath: `${params.slug}.mdx`,
})
return (
<article>
<h1>{data.post.title}</h1>
<TinaMarkdown content={data.post.body} />
</article>
)
}
Deploy on Your VPS
# Build
npm run build
# systemd service
sudo cat > /etc/systemd/system/tina-site.service