Sanity
How to Prevent Runtime Errors in CMS-Driven Next.js Websites
Learn how to prevent CMS runtime errors in Next.js apps using TypeScript, Sanity typegen, defensive coding, and error boundaries for robust, production-ready sites.

Every Next.js developer who has integrated a headless CMS has felt it — that sinking feeling when a production page crashes because an editor left a field blank, or a content type changed shape without warning. CMS runtime errors are among the most frustrating bugs to track down: they don't appear in your local environment, they slip past CI, and they surface only when real content hits your frontend. This post is a practical, TypeScript-first guide to eliminating them for good.
Common runtime errors in CMS-driven sites
CMS-driven Next.js apps fail at runtime for a predictable set of reasons. Understanding the patterns helps you build defenses before they bite you.
TypeError: Cannot read properties of undefined (or null)
This is the classic crash. An editor publishes an article without filling in the author field, and your component tries to render post.author.name — instant crash.
// ❌ Unsafe — crashes when author is undefined
const AuthorCard = ({ post }: { post: Post }) => (
<div>
<img src={post.author.avatar.url} alt={post.author.name} />
<span>{post.author.name}</span>
</div>
);
Missing or renamed fields
A content editor renames a field in the CMS schema (e.g., heroImage → coverImage). The old field returns undefined on every document until you update your queries.
Shape mismatches and optional chaining pitfalls
A field that was previously a string is migrated to a rich-text array. Your component still calls .toUpperCase() on it and throws. Optional chaining (?.) silences errors but can produce silent undefined values that propagate deep into your render tree, causing subtle layout bugs instead of loud crashes.
// ⚠️ Silently broken — renders nothing instead of crashing
const title = post?.seo?.title?.toUpperCase();
// title is undefined, but no error is thrown
The null-safety problem with CMS data
CMS data is inherently optional. Unlike a database with NOT NULL constraints, most headless CMS platforms allow editors to save documents with empty fields. This is by design — drafts, partial content, and staged publishing are all valid workflows. But it means your frontend must treat every field as potentially absent.
Why editors leave fields empty:
- Drafts saved mid-workflow
- Optional fields that are rarely filled in
- Bulk imports with incomplete data
- Schema migrations where old documents haven't been backfilled
A GROQ query returns exactly what's in the document. If post.seo.description was never set, the query returns null or omits the key entirely — and TypeScript's string type annotation won't save you if you cast the response with as.
// ❌ Unsafe — casting away the problem
const data = await client.fetch<Post>(query);
// TypeScript is happy, but data.seo.description may be null at runtime
// ✅ Safe — model the actual shape
type Post = {
title: string;
seo?: {
title?: string | null;
description?: string | null;
};
};
const renderSeoDescription = (post: Post): string =>
post.seo?.description ?? 'Read our latest article.';
The key insight: your TypeScript types must reflect what the CMS actually returns, not what you wish it returned.
Using TypeScript and Sanity typegen to prevent errors
Sanity's sanity typegen command generates TypeScript types directly from your schema and GROQ queries. This is the single most powerful tool for catching CMS runtime errors at compile time rather than in production.
Step 1 — Generate types from your schema
Run the following in your Sanity project:
npx sanity typegen generate
This produces a sanity.types.ts file containing types for every document type and object in your schema.
Step 2 — Type your GROQ queries with defineQuery
import { defineQuery } from 'next-sanity';
import type { PostQueryResult } from '@/sanity/sanity.types';
export const postQuery = defineQuery(`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
"slug": slug.current,
publishedAt,
excerpt,
body,
author->{ name, "avatar": image.asset->url },
seo { title, description }
}
`);
export async function getPost(slug: string): Promise<PostQueryResult> {
return client.fetch(postQuery, { slug });
}
Step 3 — Let TypeScript catch the errors
Because sanity typegen knows which fields are optional in your schema, the generated PostQueryResult type will mark them as string | null or T | undefined. TypeScript will now refuse to compile code that accesses them unsafely:
const post = await getPost('my-slug');
// ❌ TypeScript error: 'post.author' is possibly null
console.log(post.author.name);
// ✅ TypeScript is satisfied
console.log(post.author?.name ?? 'Anonymous');
Pair sanity typegen with "strict": true in your tsconfig.json to get the full benefit.
Defensive coding patterns
Even with generated types, you need runtime patterns that handle missing data gracefully. Here are the essential tools.
Optional chaining (?.)
const avatarUrl = post.author?.image?.asset?.url;
Nullish coalescing (??)
Provide a fallback when a value is null or undefined (but not 0 or ''):
const readingTime = post.readingTime ?? estimateReadingTime(post.body);
const seoTitle = post.seo?.title ?? post.title;
Default values in destructuring
const { title = 'Untitled', tags = [], excerpt = '' } = post;
Guard clauses
Return early when required data is missing rather than letting the component render in a broken state:
const PostPage = ({ post }: { post: PostQueryResult }) => {
if (!post) return notFound();
if (!post.title) return <ErrorState message="Post is missing a title" />;
return <article>{/* safe to render */}</article>;
};
Type narrowing
function isPublishedPost(post: PostQueryResult): post is PublishedPost {
return (
post !== null &&
typeof post.title === 'string' &&
typeof post.publishedAt === 'string'
);
}
if (isPublishedPost(post)) {
// TypeScript knows post.title and post.publishedAt are strings here
console.log(post.title.toUpperCase());
}
Error boundaries in React
Even with all the defensive patterns above, unexpected errors can still slip through — especially in complex CMS-driven sections with deeply nested components. React error boundaries are your last line of defense.
A TypeScript error boundary component
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class CMSErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[CMSErrorBoundary]', error, info.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="cms-error">
<p>This section is temporarily unavailable.</p>
</div>
);
}
return this.props.children;
}
}
Scoping error boundaries to CMS sections
Don't wrap your entire page in a single error boundary — that hides too much. Instead, scope them around individual CMS-driven sections:
export default function ArticlePage({ post }: { post: Post }) {
return (
<main>
<ArticleHeader title={post.title} />
<CMSErrorBoundary fallback={<p>Body content unavailable.</p>}>
<PortableText value={post.body} />
</CMSErrorBoundary>
<CMSErrorBoundary fallback={null}>
<RelatedPosts ids={post.relatedPosts} />
</CMSErrorBoundary>
</main>
);
}
This way, a crash in RelatedPosts doesn't take down the entire article.
Handling missing content gracefully
Not every missing field should be an error. Sometimes the right response is a graceful fallback, a skeleton state, or a redirect.
notFound() in Next.js App Router
When a required document doesn't exist at all, use Next.js's built-in notFound() to render your 404 page:
import { notFound } from 'next/navigation';
export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
if (!post) {
notFound(); // Renders app/not-found.tsx
}
return <Article post={post} />;
}
Fallback UI for optional fields
const AuthorBio = ({ author }: { author?: Author | null }) => {
if (!author) {
return <div className="author-placeholder">Written by our editorial team.</div>;
}
return (
<div className="author-bio">
{author.image && <img src={author.image.url} alt={author.name} />}
<p>{author.bio ?? 'No bio available.'}</p>
</div>
);
};
Skeleton states for async CMS data
const PostCard = ({ slug }: { slug: string }) => {
const { data: post, isLoading } = usePost(slug);
if (isLoading) return <PostCardSkeleton />;
if (!post) return null;
return <article>{post.title}</article>;
};
Fallback pages with generateStaticParams
export const dynamicParams = true;
export async function generateStaticParams() {
const slugs = await getAllPostSlugs();
return slugs.map((slug) => ({ slug }));
}
Testing CMS-driven components
The best way to prevent CMS runtime errors from reaching production is to test your components against the full range of CMS data shapes — including missing and null fields.
Unit testing with mock CMS data
Create typed mock factories that let you easily test partial and missing data:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { PostCard } from './PostCard';
import type { PostQueryResult } from '@/sanity/sanity.types';
const mockPost = (
overrides: Partial<PostQueryResult> = {}
): PostQueryResult => ({
_id: 'test-id',
title: 'Test Post',
slug: 'test-post',
excerpt: 'A test excerpt.',
publishedAt: '2026-01-01T00:00:00Z',
author: null,
seo: null,
...overrides,
});
describe('PostCard', () => {
it('renders without crashing when author is null', () => {
render(<PostCard post={mockPost({ author: null })} />);
expect(screen.getByText('Test Post')).toBeInTheDocument();
});
it('renders without crashing when seo is missing', () => {
render(<PostCard post={mockPost({ seo: undefined })} />);
expect(screen.getByText('Test Post')).toBeInTheDocument();
});
it('shows fallback author text when author is null', () => {
render(<PostCard post={mockPost({ author: null })} />);
expect(screen.getByText(/editorial team/i)).toBeInTheDocument();
});
});
Integration testing and CI strategies
Seed your test environment with documents that have intentionally missing optional fields, and add a CI step that regenerates types and fails the build on TypeScript errors:
// package.json scripts
{
"typecheck": "tsc --noEmit",
"typegen": "sanity typegen generate",
"ci:types": "npm run typegen && npm run typecheck"
}
Common mistakes to avoid
Here are the most frequent mistakes developers make when working with CMS data in Next.js:
- Casting with
asto silence TypeScript — Usingdata as Postbypasses null checks and gives you false confidence. Always use generated types or explicit type guards. - Assuming fields are always present — Just because a field is required in your CMS schema doesn't mean old documents have it. Schema changes are not retroactive without a migration.
- Wrapping the entire page in one error boundary — A single top-level boundary hides too much. Scope boundaries to individual CMS-driven sections.
- Not testing null/undefined edge cases — Most component tests use happy-path mock data. Add explicit tests for every optional field being
nullorundefined. - Ignoring strictNullChecks — Turning off strict null checks in
tsconfig.jsondefeats the purpose of TypeScript for CMS data. Always keep it enabled.
Best practices
A summary of actionable best practices for preventing CMS runtime errors in Next.js:
- Run
sanity typegen generatein CI — Regenerate types on every build so schema changes are caught before deployment. - Enable
"strict": truein tsconfig.json — This activatesstrictNullChecksand forces you to handle every nullable field explicitly. - Model your types to match reality — Mark fields as
string | nullorT | undefinedto reflect what the CMS actually returns. - Use guard clauses and notFound() — Fail fast at the page level for missing required documents, and degrade gracefully for optional fields.
- Scope error boundaries to CMS sections — Wrap individual CMS-driven components so a single broken field doesn't crash the whole page.
- Write tests for missing data — Use typed mock factories and explicitly test every optional field with
nullandundefinedvalues.
FAQ
Why do CMS runtime errors happen even when I have TypeScript types?
TypeScript types are erased at runtime. If you cast your CMS response with as MyType without validating the actual shape, TypeScript can't protect you. Use sanity typegen to generate types that accurately reflect nullable fields, and pair them with runtime guard clauses.
Does Sanity guarantee that required fields are always present in query results?
No. Sanity's schema validation runs in the Studio UI, but it doesn't prevent documents from being saved with missing fields via the API, migrations, or older documents that predate a schema change. Always treat CMS data as potentially incomplete.
What's the difference between null and undefined in Sanity GROQ responses?
GROQ returns null for fields that exist in the document but have no value, and omits keys entirely (resulting in undefined in JavaScript) for fields that were never set. Your TypeScript types should account for both: string | null | undefined.
Should I use Zod or another validation library to validate CMS data at runtime?
It's a valid approach, especially for critical data paths. Zod schemas can parse and validate CMS responses at the API boundary, throwing early with a clear error message rather than crashing deep in a component tree. The tradeoff is added bundle size and parsing overhead.
How do I handle CMS runtime errors in Next.js without crashing the whole page?
Use a combination of guard clauses (return notFound() for missing documents), React error boundaries scoped to CMS sections, and fallback UI for optional fields. This way, a missing author bio doesn't take down your entire article page.
Conclusion
Preventing CMS runtime errors in Next.js is not about being defensive to the point of paranoia — it's about building systems that are honest about the nature of CMS data. Editors will leave fields blank. Schemas will change. Old documents will have unexpected shapes. The developers who ship reliable CMS-driven sites are the ones who model this reality in their TypeScript types, validate it at the boundaries, and degrade gracefully when something is missing.
Start with sanity typegen generate, enable strictNullChecks, and add tests for your null and undefined edge cases. These three steps alone will eliminate the vast majority of CMS runtime errors before they ever reach your users. Your future self — and your on-call rotation — will thank you.


