Sanity is a flexible, real-time headless CMS with a powerful structured content platform. Paired with a Next.js frontend deployed on your VPS, you get a fast, customizable website with a world-class editing experience. This guide covers self-hosting the complete stack.
Architecture Overview
Unlike fully self-hosted CMSs, Sanity uses a hybrid approach:
- Sanity Studio: The editing interface — deployed on your VPS or hosted
- Content Lake: Sanity's hosted real-time database (free tier: 500K API requests/month)
- Next.js Frontend: Your website, fully self-hosted on your VPS
Set Up the Sanity Project
# Create a new Sanity project
npm create sanity@latest -- --project-id YOUR_PROJECT_ID \
--dataset production --template blog
cd my-sanity-project
# Define your content schema
# schemas/post.ts
export default {
name: 'post',
title: 'Blog Post',
type: 'document',
fields: [
{
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule: any) => Rule.required(),
},
{
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title', maxLength: 96 },
},
{
name: 'mainImage',
title: 'Main Image',
type: 'image',
options: { hotspot: true },
},
{
name: 'body',
title: 'Body',
type: 'blockContent',
},
{
name: 'publishedAt',
title: 'Published At',
type: 'datetime',
},
],
}
Build the Next.js Frontend
# Create Next.js app
npx create-next-app@latest my-website --typescript --app
cd my-website
# Install Sanity client
npm install next-sanity @sanity/image-url @portabletext/react
# Configure Sanity client
# lib/sanity.ts
import { createClient } from 'next-sanity'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: 'production',
apiVersion: '2025-01-01',
useCdn: true,
})
// Fetch posts
export async function getPosts() {
return client.fetch(`
*[_type == "post"] | order(publishedAt desc) {
_id, title, slug, mainImage, publishedAt,
"excerpt": array::join(string::split(pt::text(body), "")[0..200], "")
}
`)
}
export async function getPost(slug: string) {
return client.fetch(`
*[_type == "post" && slug.current == $slug][0] {
_id, title, slug, mainImage, body, publishedAt,
"author": author->{name, image}
}
`, { slug })
}
Create Dynamic Pages
// app/blog/[slug]/page.tsx
import { getPost, getPosts } from '@/lib/sanity'
import { PortableText } from '@portabletext/react'
import { notFound } from 'next/navigation'
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post: any) => ({
slug: post.slug.current,
}))
}
export default async function PostPage({
params
}: { params: { slug: string } }) {
const post = await getPost(params.slug)
if (!post) notFound()
return (
<article className="max-w-3xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<time className="text-gray-500">
{new Date(post.publishedAt).toLocaleDateString()}
</time>
<div className="prose mt-8">
<PortableText value={post.body} />
</div>
</article>
)
}
Deploy Both on Your VPS
# Build Next.js
cd /opt/my-website
npm run build
# Create systemd service
sudo cat > /etc/systemd/system/nextjs-site.service