Velo uses a highly secure, multi-tenant Headless CMS approach. Every client (tenant) has their own completely isolated Sanity database.
The frontend must NEVER hardcode a projectId in sanity.config.ts or similar files. The connection must be strictly dynamic.
The frontend must look up the tenant's configuration from the Velo database. Determine the slug from the hostname or URL.
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
const { data: org } = await supabase
.from('organizations')
.select('sanity_project_id, sanity_dataset')
.eq('slug', tenantSlug) // Dynamically determined
.single();Use the sanity_project_id to initialize the client on the fly.
import { createClient } from '@sanity/client'
const sanityClient = createClient({
projectId: org.sanity_project_id,
dataset: org.sanity_dataset || 'production',
apiVersion: '2024-05-15',
useCdn: true,
});
const query = `*[_type == "post" && status == "published"] | order(publishedAt desc) {
_id,
title,
"slug": slug.current,
mainImage { asset->{ url } },
publishedAt,
body, // TipTap JSON format
seo
}`;
const posts = await sanityClient.fetch(query);Velo stores body content as stringified TipTap JSON. You must parse and render it using TipTap extensions.
import { generateHTML } from '@tiptap/html'
import StarterKit from '@tiptap/starter-kit'
// 1. Parse the JSON
const jsonContent = JSON.parse(post.body);
// 2. Generate HTML (or use a TipTap component)
const html = generateHTML(jsonContent, [StarterKit]);
// 3. Render
<div dangerouslySetInnerHTML={{ __html: html }} className="prose" />Velo does NOT use standard Sanity Portable Text. Ignore instructions about @portabletext/react. Use TipTap utilities instead.
The projectId is different for every customer. Always fetch it dynamically from the organizations table in Supabase.
Never expose the sanity_write_token on the frontend. Use it only in server-side dashboard actions.