
Patterns I use to keep a Next.js App Router project maintainable: strict TypeScript, CMS-driven content with Sanity, stable GROQ queries, and practical trade-offs when the CMS evolves faster than the code.
Content-driven apps fail when the schema evolves and the UI can’t keep up. My goal is to keep the app type-safe while still letting content authors move fast in Sanity.
- Define schemas with clear required fields and stable document IDs.
- Keep GROQ queries explicit and predictable.
- Use TypeScript to model “nullable content” safely.
- Prefer CMS truth over hardcoded fallback data.
- Handle missing fields gracefully (null checks) but fix content at the source.
- Add new fields via schema deploys, then backfill documents.
- Use CI/CD to catch type regressions early.
- Keep performance predictable by staying SSG where possible.
1import { defineField, defineType } from 'sanity';
2
3// Define schema with full TypeScript support
4export const postSchema = defineType({
5 name: 'post',
6 title: 'Blog Post',
7 type: 'document',
8 fields: [
9 defineField({
10 name: 'title',
11 title: 'Title',
12 type: 'string',
13 validation: (Rule) => Rule.required().max(80),
14 }),
15 defineField({
16 name: 'slug',
17 title: 'Slug',
18 type: 'slug',
19 options: {
20 source: 'title',
21 maxLength: 96,
22 },
23 validation: (Rule) => Rule.required(),
24 }),
25 defineField({
26 name: 'author',
27 title: 'Author',
28 type: 'reference',
29 to: [{ type: 'author' }],
30 validation: (Rule) => Rule.required(),
31 }),
32 defineField({
33 name: 'publishedAt',
34 title: 'Published at',
35 type: 'datetime',
36 initialValue: () => new Date().toISOString(),
37 }),
38 defineField({
39 name: 'content',
40 title: 'Content',
41 type: 'array',
42 of: [
43 { type: 'block' },
44 { type: 'image' },
45 { type: 'codeBlock' },
46 ],
47 }),
48 ],
49});1import { defineQuery } from 'next-sanity';
2import { sanityFetchStatic } from '@/sanity/lib/staticFetch';
3
4// ✅ GROQ query with defineQuery for type inference
5const POST_QUERY = defineQuery(`
6 *[_type == "post" && slug.current == $slug][0]{
7 title,
8 "slug": slug.current,
9 publishedAt,
10 "author": author->{
11 name,
12 image,
13 bio
14 },
15 content,
16 _updatedAt
17 }
18`);
19
20// TypeScript infers the return type from the query!
21export async function getPost(slug: string) {
22 const post = await sanityFetchStatic({
23 query: POST_QUERY,
24 params: { slug },
25 });
26
27 // ✅ Type-safe access: TypeScript knows post.author.name exists
28 return post;
29}
30
31// Generate types from GROQ queries
32type PostQueryResult = Awaited<ReturnType<typeof getPost>>;
33// PostQueryResult = {
34// title: string;
35// slug: string;
36// publishedAt: string;
37// author: { name: string; image: SanityImage; bio: string };
38// content: Array<...>;
39// _updatedAt: string;
40// }1import type { PortableTextBlock } from '@portabletext/types';
2import type { SanityImageSource } from '@sanity/image-url/lib/types/types';
3
4// Define union types for Portable Text blocks
5type CodeBlock = {
6 _type: 'codeBlock';
7 _key: string;
8 code: string;
9 language: string;
10 filename?: string;
11};
12
13type ImageBlock = {
14 _type: 'image';
15 _key: string;
16 asset: {
17 _ref: string;
18 _type: 'reference';
19 };
20 alt?: string;
21 caption?: string;
22};
23
24type ContentBlock = PortableTextBlock | CodeBlock | ImageBlock;
25
26// Type guards for runtime type checking
27export function isCodeBlock(block: ContentBlock): block is CodeBlock {
28 return block._type === 'codeBlock';
29}
30
31export function isImageBlock(block: ContentBlock): block is ImageBlock {
32 return block._type === 'image';
33}
34
35// Usage in component
36export function renderContent(blocks: ContentBlock[]) {
37 return blocks.map((block) => {
38 if (isCodeBlock(block)) {
39 // ✅ TypeScript knows block has code, language, filename
40 return <CodeBlock key={block._key} code={block.code} language={block.language} />;
41 }
42
43 if (isImageBlock(block)) {
44 // ✅ TypeScript knows block has asset, alt, caption
45 return <Image key={block._key} src={urlFor(block.asset)} alt={block.alt} />;
46 }
47
48 // Regular text block
49 return <PortableText key={block._key} value={block} />;
50 });
51}1import { z } from 'zod';
2
3// Define Zod schema for runtime validation
4const PostSchema = z.object({
5 _id: z.string(),
6 _type: z.literal('post'),
7 title: z.string().max(80),
8 slug: z.object({
9 current: z.string(),
10 }),
11 publishedAt: z.string().datetime(),
12 author: z.object({
13 _ref: z.string(),
14 _type: z.literal('reference'),
15 }),
16 content: z.array(
17 z.discriminatedUnion('_type', [
18 z.object({
19 _type: z.literal('block'),
20 _key: z.string(),
21 children: z.array(z.object({
22 _type: z.literal('span'),
23 text: z.string(),
24 })),
25 }),
26 z.object({
27 _type: z.literal('codeBlock'),
28 _key: z.string(),
29 code: z.string(),
30 language: z.string(),
31 }),
32 ])
33 ),
34});
35
36// Type inferred from Zod schema
37type Post = z.infer<typeof PostSchema>;
38
39// Validate Sanity data at runtime
40export function validatePost(data: unknown): Post {
41 try {
42 return PostSchema.parse(data);
43 } catch (error) {
44 if (error instanceof z.ZodError) {
45 console.error('Sanity data validation failed:', error.errors);
46 }
47 throw new Error('Invalid post data from Sanity');
48 }
49}
50
51// Use in API route or page
52export async function getValidatedPost(slug: string): Promise<Post> {
53 const rawPost = await sanityFetchStatic({ query: POST_QUERY, params: { slug } });
54 return validatePost(rawPost);
55}1import { defineField, defineType } from 'sanity';
2
3// Version 1: Initial schema
4export const postSchemaV1 = defineType({
5 name: 'post',
6 title: 'Blog Post',
7 type: 'document',
8 fields: [
9 defineField({
10 name: 'title',
11 type: 'string',
12 }),
13 ],
14});
15
16// Version 2: Added optional fields (backward compatible)
17export const postSchemaV2 = defineType({
18 name: 'post',
19 title: 'Blog Post',
20 type: 'document',
21 fields: [
22 defineField({
23 name: 'title',
24 type: 'string',
25 }),
26 // ✅ New optional fields don't break existing documents
27 defineField({
28 name: 'excerpt',
29 type: 'text',
30 title: 'Excerpt',
31 }),
32 defineField({
33 name: 'tags',
34 type: 'array',
35 of: [{ type: 'string' }],
36 }),
37 ],
38});
39
40// Migration function for data transformation
41export async function migratePostsToV2() {
42 const client = createClient({ /* ... */ });
43
44 // Fetch all posts
45 const posts = await client.fetch(`*[_type == "post"]`);
46
47 // Transform each post
48 const mutations = posts.map((post) => ({
49 patch: {
50 id: post._id,
51 set: {
52 // Auto-generate excerpt from title if missing
53 excerpt: post.excerpt || post.title.slice(0, 100),
54 // Initialize empty tags array
55 tags: post.tags || [],
56 },
57 },
58 }));
59
60 // Execute mutations
61 await client.transaction(mutations).commit();
62 console.log(`Migrated ${posts.length} posts to v2`);
63}
64
65// TypeScript types that handle both versions
66type PostV1 = {
67 _id: string;
68 title: string;
69};
70
71type PostV2 = PostV1 & {
72 excerpt?: string;
73 tags?: string[];
74};
75
76// Type guard to check version
77export function isPostV2(post: PostV1 | PostV2): post is PostV2 {
78 return 'excerpt' in post || 'tags' in post;
79}1// Install: pnpm add -D groq-codegen
2
3// groq-codegen.config.ts
4import { defineConfig } from 'groq-codegen';
5
6export default defineConfig({
7 schemaPath: './sanity/schema.ts',
8 outputPath: './sanity/types.generated.ts',
9});
10
11// Then run: pnpm groq-codegen
12
13// Generated types (sanity/types.generated.ts)
14export type Post = {
15 _id: string;
16 _type: 'post';
17 title: string;
18 slug: { current: string };
19 author: {
20 _ref: string;
21 _type: 'reference';
22 };
23 content: Array<PortableTextBlock | CodeBlock | ImageBlock>;
24};
25
26// Now use in queries with full type safety
27import type { Post } from './sanity/types.generated';
28
29const POST_QUERY = defineQuery(`
30 *[_type == "post"][0...10]{
31 _id,
32 title,
33 "slug": slug.current,
34 "author": author->name
35 }
36`);
37
38export async function getPosts(): Promise<Post[]> {
39 return await sanityFetchStatic({ query: POST_QUERY });
40 // ✅ Return type is validated at compile time!
41}Want a consultant? Click here to schedule a call.
Schedule a call
A practical guide to WebMCP: what it is, how to enable it in Chrome, how LLMs communicate with it, best practices, and realistic time savings for product and engineering teams.

Agent skills make AI assistants reliable: repeatable workflows, safe defaults, and less prompt churn. Here’s how Claude Code skills and Vercel’s React rules help teams ship faster with fewer regressions.

A practical playbook for improving day-to-day delivery: connect Slack, Jira, Monday.com, and GitHub using MCP-based AI agents for notifications, triage, status sync, code review feedback loops, and automated follow-ups.
Want to see how AI chat can build you automation workflows?
Try AI Dashboard →Wherever you are in the world, let's work together on your next project.
Israel
Prefer to talk directly? Schedule a call and we can discuss your project live.
Schedule a call