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.

June 26, 202610 min readMuhammad Zohaib Ramzan
AI-generated editorial illustration created for this article.

Image detail

Editorial illustration of a safety net of validation gates catching malformed content before it reaches a page

From reading to action

A practical path through this guide.

Select a step to explore what to do and why it matters.

STEP 01 / 03

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:

  1. Casting with as to silence TypeScript — Using data as Post bypasses null checks and gives you false confidence. Always use generated types or explicit type guards.
  2. 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.
  3. Wrapping the entire page in one error boundary — A single top-level boundary hides too much. Scope boundaries to individual CMS-driven sections.
  4. Not testing null/undefined edge cases — Most component tests use happy-path mock data. Add explicit tests for every optional field being null or undefined.
  5. Ignoring strictNullChecks — Turning off strict null checks in tsconfig.json defeats 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 generate in CI — Regenerate types on every build so schema changes are caught before deployment.
  • Enable "strict": true in tsconfig.json — This activates strictNullChecks and forces you to handle every nullable field explicitly.
  • Model your types to match reality — Mark fields as string | null or T | undefined to 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 null and undefined values.

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.