Why a CMS next to MongoDB
MongoDB is excellent for application data (users, tools, carts). It is a poor CMS: no real editing UI, no draft/publish, no portable text, no image pipeline. Sanity is a content lake with Studio. Editors publish; Next.js fetches.
| Store in Sanity | Store in MongoDB |
|---|---|
| Posts, courses, landing copy | Users, sessions, tools catalog |
| Portable Text, figures, SEO fields | Orders, shops, licenses |
| Draft → publish workflow | High-frequency writes |
Documents
Everything is a document with _id, _type, and fields you define. A post is not a page in the CMS — the website decides the URL (/blog/[slug]) from slug.current.
Schemas
A schema is TypeScript: name, type: "document", and fields. Keep them under sanity/ (or apps/web/sanity) and generate types so queries stay honest.
import { defineField, defineType } from "sanity";
export const post = defineType({
name: "post",
title: "Post",
type: "document",
fields: [
defineField({ name: "title", type: "string", validation: (r) => r.required() }),
defineField({ name: "slug", type: "slug", options: { source: "title" } }),
defineField({ name: "excerpt", type: "text" }),
defineField({ name: "body", type: "array", of: [{ type: "block" }] }),
defineField({ name: "publishedAt", type: "datetime" }),
],
});Studio
Studio is the editing UI (/studio or a hosted project). Desk structure, previews, and plugins live next to the schema. Editors never open the Git repo. Developers never copy-paste blog HTML.
- Drafts are separate from published documents.
- Role-based access belongs in the Sanity project, not in Next.js pages.
- Custom input components only when a field is painful — default string/text/image cover 90%.
GROQ
GROQ is Sanity’s query language. Filter by _type, project fields, follow references. Wrap queries in defineQuery so typegen can see them.
*[_type == "post" && defined(slug.current)]
| order(publishedAt desc) {
title,
"slug": slug.current,
excerpt,
publishedAt
}Portable Text
body is an array of blocks, not a markdown string. Render it with @portabletext/react (or a small custom serializer) so headings, links, and figures map to your design tokens — not to Sanity’s default HTML.
Images
Sanity stores the asset; you build URLs with @sanity/image-url. Cap Open Graph images to 1200×630. This repo has images.unoptimized: true — size on the Sanity URL, not via next/image optimization.
urlFor(post.mainImage).width(1200).height(630).url()Fetching in Next.js
Query on the server. ReactBD wraps the client in serverFetch with revalidate in production and revalidate: 0 in dev so Studio edits show up immediately locally.
export async function getAllPosts() {
return serverFetch(allPostsQuery, {}, {
revalidate: 1800,
tags: ["posts"],
});
}Revalidation
When an editor publishes, a webhook (or a Studio action) should revalidateTag("posts"). Until then, the revalidate TTL is the worst-case delay. Do not force-dynamic a blog index just to see a new post — tag it.
How ReactBD uses Sanity
- Courses, some marketing pages, and CMS-backed listings query Sanity on the server.
- Sitemap merges static routes with Sanity slugs (
getAllCourses,getMaterialsSlug). - The study materials you are reading are not Sanity — they are TypeScript modules, generated statically on purpose.
- When you add a document type, add its public URLs to
app/sitemap.ts.