From reading to action
A practical path through this guide.
Select a step to explore what to do and why it matters.
Identify missing-data boundaries
Inspect optional fields, references and absent documents.
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.
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.
A few practical answers
Frequently asked questions
Why can a correctly typed CMS page still fail at runtime?
External content may be missing, nullable or older than the current schema. Type assertions do not validate arbitrary data; rendering needs deliberate handling of those states.
Should every missing CMS field cause an error page?
No. Choose sensible optional-field fallbacks while treating missing required documents differently. Use error boundaries for unexpected failures and verify incomplete content cases.


