Building scalable, conversion-driven digital products that bridge strategy, design, and engineering. From Next.js and React to Stripe and Python — I craft seamless user experiences that grow businesses and delight customers.
Production-tested patterns from real implementations
The Ultimate Guide to Sanity CMS Architecture
You're evaluating Sanity for your next project, or you've already committed and need to build something that won't fall apart at scale. These are the architectural decisions that separate projects that scale from projects that become technical debt.
Schema Architecture: The Foundation That Determines Everything
Most developers treat schemas like database tables. That's wrong. Sanity schemas are your content API, your editorial interface, and your data validation layer all in one. Getting this wrong means painful migrations later.
Document Types vs Objects: Know The Difference
Documents live at the root. They get their own URLs in the Studio, their own edit views, their own permissions. Use them for products, events, blog posts, pages, and anything editors need to manage independently.
Objects are reusable structures that live inside documents or other objects. Use them for address fields, SEO metadata, pricing structures, and media with captions.
The rule: if it needs to be referenced across multiple documents, make it a document. If it's always part of something else, make it an object.
References: The Right Way
Weak references are your default. They point to another document by ID. The referenced document can be deleted, moved, or unpublished without breaking your content. Use weak references for related products, author attribution, category assignments, and content relationships that might change.
Common mistake: Storing Stripe product IDs as references. They're not documents. Store them as strings in a field. Same for external IDs from Salesforce, HubSpot, or any other system.
Block Content (Portable Text): Your Rich Text Solution
Block content is Sanity's answer to rich text. It's an array of blocks and objects that can be anything: paragraphs, images, custom components, embedded forms, whatever you need.
Basic Structure
1 { 2 name: 'content', 3 type: 'array', 4 of: [ 5 { type: 'block' }, // Standard rich text 6 { type: 'image' }, // Images 7 { 8 type: 'reference', 9 to: [{ type: 'product' }] // Embed product references 10 } 11 ] 12 }
The power is in custom blocks. Need an embedded form? Add it:
1 { 2 type: 'object', 3 name: 'formEmbed', 4 fields: [ 5 { 6 name: 'form', 7 type: 'reference', 8 to: [{ type: 'form' }] 9 }, 10 { 11 name: 'displayType', 12 type: 'string', 13 options: { 14 list: ['inline', 'modal', 'sidebar'] 15 } 16 } 17 ] 18 }
Now your editors can drop forms anywhere in content. Your frontend renders them however you need. This is how you build flexible content systems.
Complete Schema Examples
Let's build some real schemas you'll actually use.
Blog Post Schema
1 export default { 2 name: 'post', 3 title: 'Blog Post', 4 type: 'document', 5 fields: [ 6 { 7 name: 'title', 8 title: 'Title', 9 type: 'string', 10 validation: Rule => Rule.required().max(100) 11 }, 12 { 13 name: 'slug', 14 title: 'Slug', 15 type: 'slug', 16 options: { 17 source: 'title', 18 maxLength: 96 19 }, 20 validation: Rule => Rule.required() 21 }, 22 { 23 name: 'author', 24 title: 'Author', 25 type: 'reference', 26 to: [{ type: 'author' }], 27 validation: Rule => Rule.required() 28 }, 29 { 30 name: 'mainImage', 31 title: 'Main Image', 32 type: 'image', 33 options: { 34 hotspot: true // Enables image cropping 35 }, 36 fields: [ 37 { 38 name: 'alt', 39 type: 'string', 40 title: 'Alternative Text', 41 validation: Rule => Rule.required() 42 } 43 ] 44 }, 45 { 46 name: 'publishedAt', 47 title: 'Published At', 48 type: 'datetime', 49 validation: Rule => Rule.required() 50 }, 51 { 52 name: 'body', 53 title: 'Body', 54 type: 'array', 55 of: [ 56 { type: 'block' }, 57 { type: 'image', options: { hotspot: true } }, 58 { type: 'code' } 59 ] 60 } 61 ] 62 }
Validation: Enforce Your Rules in the Schema
Validation prevents garbage data. Always validate at the schema level:
1 // URLs 2 { 3 name: 'website', 4 type: 'url', 5 validation: Rule => Rule.uri({ scheme: ['http', 'https'] }) 6 } 7 8 // Email 9 { 10 name: 'email', 11 type: 'string', 12 validation: Rule => Rule.email() 13 } 14 15 // Date ranges 16 { 17 name: 'endDate', 18 type: 'datetime', 19 validation: Rule => Rule.min(Rule.valueOfField('startDate')) 20 } 21 22 // String length for SEO 23 { 24 name: 'metaDescription', 25 type: 'text', 26 validation: Rule => Rule.max(160).warning('Keep under 160 characters for SEO') 27 }
The schema is your contract. Make it strict.
GROQ: Sanity's Query Language
GROQ (Graph-Relational Object Queries) is how you get data from Sanity. It's powerful, flexible, and easy to screw up. Here's everything you need to know.
Basic Query Syntax
1 // Fetch all documents of a type 2 *[_type == "product"] 3 4 // Fetch a single document 5 *[_type == "product" && slug.current == "red-shoes"][0] 6 7 // Fetch with parameters (prevents injection attacks) 8 *[_type == "product" && slug.current == $slug][0] 9 10 // Sorting 11 *[_type == "product"] | order(price asc) 12 13 // Limit results (top 10 most expensive) 14 *[_type == "product"] | order(price desc)[0..9]
Projections: Only Fetch What You Need
1 // Bad query (fetches everything) 2 *[_type == "product"] 3 4 // Good query (fetches only what you need) 5 *[_type == "product"] { 6 _id, 7 title, 8 price, 9 slug 10 } 11 12 // Rename fields in results 13 *[_type == "product"] { 14 "id": _id, 15 "name": title, 16 price 17 } 18 19 // Computed fields 20 *[_type == "product"] { 21 title, 22 price, 23 "discountPrice": price * 0.8, 24 "inStock": inventory > 0 25 }
Working With References
References are where GROQ gets powerful. The -> operator resolves references:
1 // Basic reference resolution 2 *[_type == "product"] { 3 title, 4 "categoryName": category->name 5 } 6 7 // Multiple fields from referenced document 8 *[_type == "product"] { 9 title, 10 "category": category-> { 11 name, 12 slug, 13 description 14 } 15 } 16 17 // Array of references 18 *[_type == "event"] { 19 title, 20 "speakers": speakers[]-> { 21 name, 22 bio, 23 "photo": image.asset->url 24 } 25 } 26 27 // Join pattern (products with their reviews) 28 *[_type == "product"] { 29 title, 30 price, 31 "reviews": *[_type == "review" && product._ref == ^._id] { 32 rating, 33 comment, 34 author 35 } 36 }
Multi-Language Architecture
You have three approaches for internationalized content. Choose wrong and you're rewriting everything.
Approach 1: Field-Level Translation (Best for Marketing Sites)
Each field gets language variants. Pros: Simple queries, all languages in one document. Cons: Schema gets verbose, hard to track translation status.
Approach 2: Document-Level Translation (Best for Content-Heavy Sites)
Each language is a separate document. Pros: Clean schemas, easy translation workflow. Cons: More complex queries, need to manage relationships.
I use field-level for marketing copy (short text, few languages). I use document-level for blogs and articles (long content, many languages).
Performance: What Actually Moves the Needle
1. Cache Everything You Can
Use Next.js revalidation with Sanity webhooks:
1 export async function GET(request) { 2 const products = await client.fetch(query, {}, { 3 next: { revalidate: 3600, tags: ['products'] } 4 }) 5 6 return Response.json(products) 7 } 8 9 // When content changes, revalidate: 10 // api/revalidate/route.ts 11 export async function POST(request) { 12 const { tag } = await request.json() 13 revalidateTag(tag) 14 return Response.json({ revalidated: true }) 15 }
2. Use CDN for Images
1 import imageUrlBuilder from '@sanity/image-url' 2 3 const builder = imageUrlBuilder(client) 4 5 function urlFor(source) { 6 return builder.image(source) 7 .width(800) 8 .format('webp') 9 .quality(80) 10 .url() 11 }
Auto-formats, auto-resizes, globally distributed. Don't host images yourself.
3. Paginate Large Datasets
Don't fetch 1,000 products at once. Use offset and limit in your queries. Build pagination in your frontend. Load more as needed.
When to Choose Sanity (and When Not To)
Use Sanity When:
- You need a content-first architecture
- You want real-time collaboration for editors
- You need flexible, structured content
- You're building with Next.js, React, or any modern framework
Don't Use Sanity When:
- You need complex relational data (use PostgreSQL)
- You need transactions (use a real database)
- Your content model is simple and never changes (use markdown files)
The Mistakes Everyone Makes
Avoid these common pitfalls when building with Sanity CMS
Ready to Build Something That Scales?
I've built e-commerce platforms handling thousands of products, event management systems with complex registration flows, and multi-language marketing sites across international markets. All on Sanity. If you need architecture review, technical rescue, or someone to build it right the first time, let's talk.