Sanity
Building a Type-Safe Sanity Client in Next.js with TypeScript and GROQ
Learn how to build a fully type-safe Sanity client in Next.js using TypeScript, sanity-typegen, and typed GROQ queries — eliminating runtime errors and boosting developer confidence.

Why Type Safety Matters in CMS-Driven Next.js Apps
Modern Next.js applications increasingly rely on headless CMS platforms like Sanity to manage content. While this separation of concerns is powerful, it introduces a subtle but dangerous gap: your TypeScript code has no idea what shape the data coming from your CMS actually is.
Without type safety, a renamed field in your Sanity schema silently breaks your frontend. You only discover the mismatch at runtime — or worse, in production. A type-safe Sanity client closes this gap entirely, giving you compile-time guarantees that your queries match your schema and your components consume the right data shapes.
In this post, you'll learn how to wire up @sanity/client, generate TypeScript types from your Sanity schema using sanity-typegen, and write typed GROQ queries that make TypeScript your first line of defence against content mismatches.
Prerequisites & Project Setup
Before diving in, make sure you have the following in place:
- Node.js 18 or later
- A Next.js 14+ project with the App Router and TypeScript enabled
- An existing Sanity project (or a new one created via
npm create sanity@latest) - Basic familiarity with GROQ, Sanity's query language
Scaffold a new Next.js app with TypeScript and the App Router:
npx create-next-app@latest my-app --typescript --app
cd my-app
Then initialise your Sanity project inside the same repo:
npm create sanity@latest -- --project <projectId> --dataset production --output-path ./studio
Installing and Configuring the Sanity Client
Install the official Sanity client package:
npm install @sanity/client
Create a dedicated client module at lib/sanity.ts. Centralising the client here means every data-fetching function imports from one place, making it easy to swap configuration later:
// lib/sanity.ts
import { createClient } from '@sanity/client';
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET ?? 'production',
apiVersion: '2024-01-01',
useCdn: process.env.NODE_ENV === 'production',
});
Add the corresponding environment variables to .env.local:
NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id
NEXT_PUBLIC_SANITY_DATASET=production
The apiVersion field pins your client to a specific Sanity API version, ensuring your queries behave consistently even as the API evolves. Always use a real date string here rather than 'v1'.
Setting Up sanity-typegen
sanity-typegen is the official Sanity CLI tool that introspects your schema and emits TypeScript type definitions. These generated types are the foundation of end-to-end type safety.
Install the Sanity CLI if you haven't already:
npm install --save-dev sanity @sanity/cli
Add a sanity-typegen.json configuration file at the root of your project:
{
"path": "./sanity/**/*.ts",
"schema": "./sanity/schemaTypes/index.ts",
"generates": "./sanity/types.generated.ts"
}
Now run the type generation command:
npx sanity typegen generate
This introspects your Sanity schema and writes a file like sanity/types.generated.ts. A typical output looks like this:
// sanity/types.generated.ts (auto-generated — do not edit)
export type Post = {
_id: string;
_type: 'post';
_createdAt: string;
_updatedAt: string;
title?: string;
slug?: { _type: 'slug'; current?: string };
excerpt?: string;
mainImage?: {
asset?: { _ref: string; _type: 'reference' };
alt?: string;
};
body?: Array<{ _key: string } & Block>;
publishedAt?: string;
};
export type AllSanitySchemaTypes = Post | Author | Category;
Add the generation command to your package.json scripts so it runs as part of your build pipeline:
"scripts": {
"typegen": "sanity typegen generate",
"prebuild": "npm run typegen",
"build": "next build"
}
Writing Typed GROQ Queries
With generated types in hand, you can now write GROQ queries that are fully typed end-to-end. The next-sanity package exports a defineQuery helper that pairs a GROQ string with its expected return type. Create a lib/queries.ts file to house your queries:
// lib/queries.ts
import { defineQuery } from 'next-sanity';
export const allPostsQuery = defineQuery(`
*[_type == "post"] | order(publishedAt desc) {
_id,
title,
slug,
excerpt,
publishedAt,
"authorName": author->name
}
`);
export const postBySlugQuery = defineQuery(`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
excerpt,
body,
publishedAt,
"authorName": author->name,
"categoryTitle": category->title
}
`);
When you run sanity typegen generate, the CLI also analyses your defineQuery calls and generates precise return types for each query. The result in types.generated.ts will include:
export type AllPostsQueryResult = Array<{
_id: string;
title: string | null;
slug: { _type: 'slug'; current?: string } | null;
excerpt: string | null;
publishedAt: string | null;
authorName: string | null;
}>;
export type PostBySlugQueryResult = {
_id: string;
title: string | null;
slug: { _type: 'slug'; current?: string } | null;
excerpt: string | null;
body: Array<Block> | null;
publishedAt: string | null;
authorName: string | null;
categoryTitle: string | null;
} | null;
Integrating the Typed Client into Next.js Data Fetching
Next.js App Router Server Components are the ideal place to call your Sanity client — they run on the server, have access to secrets, and their output is streamed to the client without shipping any client-side JS for the fetch logic.
Here's a complete example of a blog listing page:
// app/blog/page.tsx
import { client } from '@/lib/sanity';
import { allPostsQuery } from '@/lib/queries';
import type { AllPostsQueryResult } from '@/sanity/types.generated';
export default async function BlogPage() {
const posts: AllPostsQueryResult = await client.fetch(allPostsQuery);
return (
<ul>
{posts.map((post) => (
<li key={post._id}>
<a href={`/blog/${post.slug?.current}`}>{post.title}</a>
<p>{post.excerpt}</p>
</li>
))}
</ul>
);
}
For dynamic routes, use generateStaticParams to pre-render all post pages at build time:
// app/blog/[slug]/page.tsx
import { client } from '@/lib/sanity';
import { allPostsQuery, postBySlugQuery } from '@/lib/queries';
import type { PostBySlugQueryResult } from '@/sanity/types.generated';
export async function generateStaticParams() {
const posts = await client.fetch(allPostsQuery);
return posts
.filter((p) => p.slug?.current)
.map((p) => ({ slug: p.slug!.current }));
}
interface Props {
params: { slug: string };
}
export default async function PostPage({ params }: Props) {
const post: PostBySlugQueryResult = await client.fetch(
postBySlugQuery,
{ slug: params.slug }
);
if (!post) return <p>Post not found.</p>;
return (
<article>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
</article>
);
}
Because PostBySlugQueryResult is typed as ... | null, TypeScript forces you to handle the null case — exactly the kind of defensive coding that prevents runtime crashes.
DX Improvements and Bug Reduction
The real payoff of this setup becomes clear the moment you make a schema change. Consider these concrete scenarios:
Renamed fields caught at compile time
Suppose you rename excerpt to summary in your Sanity schema. After running npm run typegen, the generated type no longer has an excerpt property. Every component that accesses post.excerpt immediately shows a TypeScript error — no runtime surprises, no broken UI in production.
Projection mismatches surfaced instantly
If your GROQ query projects "authorName": author->name but your component tries to access post.author.name, TypeScript will flag the mismatch. The generated type reflects exactly what the query returns, not the full document shape.
Null safety enforced
Sanity fields are optional by default, so generated types reflect this with string | null unions. TypeScript forces you to handle nulls explicitly, eliminating a whole class of Cannot read properties of undefined errors.
These improvements compound over time. On a large team, the type-safe pipeline acts as a contract between the content team (who own the schema) and the engineering team (who own the frontend). Schema changes become safe, documented, and immediately visible across the codebase.
Conclusion and Next Steps
You now have a fully type-safe pipeline from Sanity schema to Next.js component:
@sanity/clientprovides the runtime connection to your Sanity projectsanity-typegengenerates TypeScript types that mirror your schema exactlydefineQueryties your GROQ queries to their precise return types- Next.js Server Components consume the typed data with full IDE autocomplete and null-safety
From here, consider these next steps to further harden your setup:
- Add Zod validation at the fetch boundary to catch schema drift between deploys
- Enable Sanity's Live Content API for real-time previews in Next.js draft mode
- Set up a CI check that runs
sanity typegen generateand fails the build if the generated file has uncommitted changes — enforcing that types are always in sync with the schema - Explore @sanity/presentation for visual editing directly within your Next.js app
Type safety in a CMS-driven app isn't just a developer convenience — it's a reliability guarantee. With this setup in place, your content and code evolve together, safely.


