Sanity TypeGen: Fully Typed GROQ Queries in TypeScript

By Nayan Kyada · · 7 min read

Part of The Sanity + Next.js Guide

Sanity TypeGen closes the gap between your GROQ queries and TypeScript by generating precise types directly from your schema and query strings. Without it, you're hand-writing interfaces that drift the moment an editor renames a field. If you've searched for "sanity typegen" or "sanity typegen generate" and wondered where to actually start, this post walks through the exact setup I use on production projects — config files, CI integration, and the schema-change edge cases that will bite you if you skip them.

Why Sanity TypeGen Matters for Typed GROQ Queries

GROQ projections can return deeply nested, nullable shapes that are genuinely hard to type by hand. Consider a query like:

*[_type == "post"]{title, "author": author->name, body[]{...}}

That single projection has at least four non-obvious TypeScript concerns:

  • The dereference nullability on author->
  • The inline array spread on body[]{...}
  • The Portable Text union type
  • The _key field injected on every block element

Sanity TypeGen reads an extracted snapshot of your schema (schema.json), parses every annotated query string, and emits a sanity.types.ts file with exact discriminated unions covering all of it automatically.

What You Get from Generated Types

The precision of the output goes beyond what you'd write by hand:

  • If a field has validation: (rule) => rule.required() and you extract the schema with --enforce-required-fields, the generated type is non-optional (string, not string | undefined)
  • Otherwise fields are optional in schema types, and query results come back as string | null — the compiler forces you to handle it
  • Dereference nullability is modeled correctly — no more silent runtime undefineds
  • Array block types produce accurate discriminated unions for every Portable Text shape

Key takeaway: TypeGen does not guess. It reads your actual schema definition and derives types from it. If the schema says a field is required (and you extract with --enforce-required-fields), the type is non-optional. Keep in mind drafts can still be invalid, so previews of unpublished content can return null for required fields. That guarantee is what makes the whole system worthwhile.

Installing and configuring the CLI

TypeGen ships with the sanity package's CLI — there's no separate codegen package to install. Use a current Studio version: TypeGen went GA in Sanity Studio v5.10.0, and the --watch flag needs v5.8.0 or later. Your project should already have a sanity.config.ts and a sanity.cli.ts.

Configure TypeGen under the typegen key in sanity.cli.ts (the old standalone sanity-typegen.json file is deprecated):

// sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'
 
export default defineCliConfig({
  api: {
    projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
    dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
  },
  typegen: {
    path: './src/**/*.{ts,tsx}',
    schema: './schema.json',
    generates: './src/sanity/types/sanity.types.ts',
    overloadClientMethods: true,
  },
})

Four fields matter here. path is the glob TypeGen scans for queries — point it at every file that runs queries. schema points at the schema.json snapshot produced by sanity schema extract (not at sanity.config.ts). generates is the output path; I keep it inside src/sanity/types/ so it sits near the query helpers that consume it. overloadClientMethods (on by default) makes client.fetch return the generated type for any query written with defineQuery, which removes the need to cast results manually.

After writing the config, extract the schema and run generation once:

npx sanity schema extract --enforce-required-fields
npx sanity typegen generate

If everything resolves you will see sanity.types.ts appear. Commit it (and schema.json). It is generated but it should live in source control — reviewers need to see type changes alongside schema changes.

Annotating queries so TypeGen can find them

TypeGen only generates types for queries that are assigned to a uniquely named variable and written with defineQuery (or the groq tagged template) from groq or next-sanity. A plain string — or a query written inline inside client.fetch() — is invisible to the scanner. Prefer defineQuery: it's what lets client.fetch infer the result type automatically.

// src/sanity/queries/post.ts
import { defineQuery, type SanityClient } from 'next-sanity'
import type { postPageQueryResult } from '../types/sanity.types'
 
export const postPageQuery = defineQuery(`
  *[_type == "post" && slug.current == $slug][0]{
    _id,
    title,
    publishedAt,
    "author": author->{ name, image },
    body
  }
`)
 
// The return type is inferred from sanity.types.ts — no manual cast needed.
export async function fetchPost(
  client: SanityClient,
  slug: string
): Promise<postPageQueryResult> {
  return client.fetch(postPageQuery, { slug })
}

When you annotate the return type as postPageQueryResult (current TypeGen preserves the variable's casing in the generated name), you are consuming what TypeGen wrote. If you delete the title projection from postPageQuery and regenerate, the type updates automatically and the compiler will flag every downstream component that tried to render post.title.

Integrating generation into CI

The workflow I use in GitHub Actions runs TypeGen as a check step before the TypeScript compiler. If the generated file has drifted from what the current schema and queries produce, the step fails:

# .github/workflows/ci.yml (relevant excerpt)
- name: Install dependencies
  run: npm ci
 
- name: Generate Sanity types
  run: npx sanity schema extract --enforce-required-fields && npx sanity typegen generate
 
- name: Check for uncommitted type changes
  run: |
    git diff --exit-code schema.json src/sanity/types/sanity.types.ts \
      || (echo "Sanity types are out of date. Run 'npm run typegen' locally and commit the result." && exit 1)
 
- name: TypeScript check
  run: npx tsc --noEmit

No API token is needed for this step: schema extraction loads your local Studio config and TypeGen reads schema.json from disk, so neither talks to your dataset.

The git diff --exit-code step is the safety net. It catches the case where a developer edited a schema file locally, forgot to regenerate, and pushed. The PR will fail loudly with a message pointing at the fix.

Gotchas when schemas evolve

Renamed fields break silently until you regenerate. If a content editor asks you to rename heroImage to coverImage in the schema, the old type still exists in sanity.types.ts until you run schema extract and typegen generate again. Your Next.js build will pass because the type file still says heroImage. The CI step above catches this; local development does not unless you run watch mode (npx sanity typegen generate --watch, alongside sanity schema extract --watch or automatic extraction during sanity dev).

Projected fields come back nullable. In query result types, a projected field that isn't enforced as required is typed | null, and a dereference like author->{...} can be null if the reference is missing or points at an unpublished document. You will need a null check, .filter(Boolean), or a type guard at the call site. Do not suppress it with a non-null assertion — the null case is real.

Inline spreads on Portable Text produce very wide unions. A body field typed as block[] with custom types attached generates a union of every possible block shape. This is correct but large. If you are hitting TypeScript performance issues (tsc taking 40+ seconds), move the Portable Text rendering to a separate component file that imports PortableTextBlock from @portabletext/types directly rather than from the generated union. Reserve the generated type for the fetch boundary only.

Schema files must be importable by Node at schema-extract time. If your sanity.config.ts imports from next/image or uses process.env.NEXT_PUBLIC_* variables without defaults, the CLI will throw. I keep a sanity.config.ts that is isomorphic — no Next.js-specific imports, environment variables read with fallbacks — and the Next.js Studio page wraps it in a use client boundary separately.

Keeping the workflow sustainable

Add TypeGen's watch mode to your dev script so types regenerate as you save (the --watch flags need Sanity v5.8.0+):

// package.json scripts excerpt
"typegen": "sanity schema extract --enforce-required-fields && sanity typegen generate",
"dev:studio": "sanity dev",
"dev:schema": "sanity schema extract --enforce-required-fields --watch",
"dev:types": "sanity typegen generate --watch",
"dev": "concurrently \"next dev\" \"npm run dev:schema\" \"npm run dev:types\""

Running both in parallel means the TypeScript language server in VS Code sees updated types within a few seconds of saving a schema change. That feedback loop is what makes the system worth the setup cost — type errors surface in the editor, not in production logs.

This typed-GROQ workflow is the kind of foundation I set up on every build. If you'd rather not wire it yourself, you can hire a Sanity CMS developer to handle the schema, types, and App Router data layer end to end.

Frequently asked questions

What is Sanity TypeGen?

Sanity TypeGen is the official CLI that reads your schema and GROQ queries and generates accurate TypeScript types for the exact shape each query returns. It replaces hand-written interfaces that drift the moment a field changes, so type errors surface in your editor instead of in production.

How do I generate TypeScript types from GROQ queries?

Wrap your queries in defineQuery, run `sanity schema extract` to snapshot the schema, then `sanity typegen generate` to emit a typed file. Wire both into one npm script and your CI step so the types regenerate whenever a schema or query changes.

Does Sanity TypeGen work with the Next.js App Router?

Yes. The generated types are plain TypeScript, so they apply to client.fetch calls in Server Components, route handlers, and generateStaticParams alike. Run the generate step in CI before next build to fail the build on any query that no longer matches the schema.