How I set up Sanity TypeGen for fully typed GROQ queries in TypeScript

May 13, 2026 · 6 min read

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. This post walks through the exact setup I use on production projects — config files, CI step, and the schema-change edge cases that will bite you if you skip them.

Why sanity typegen typed GROQ queries matter in TypeScript

Groq projections can return deeply nested, nullable shapes that are hard to type by hand. A query like *[_type == "post"]{title, "author": author->name, body[]{...}} has at least four non-obvious TypeScript concerns: the dereference nullability, the inline array spread, the Portable Text union, and the _key field injected on every block. Sanity TypeGen reads your schema at build time, parses every query string you annotate, and emits a sanity.types.ts file with exact discriminated unions for all of it.

The net result: if a schema field is marked required: true the generated type is string, not string | null. If it is optional, you get string | null and the compiler forces you to handle it. No more silent runtime undefineds.

Installing and configuring the CLI

You need @sanity/cli at v3.30 or later and a Sanity project already running its Studio from a sanity.config.ts file. Install the codegen package alongside:

npm install --save-dev @sanity/codegen

Then create sanity.codegen.json at the repository root (same level as sanity.config.ts):

// sanity.codegen.json
{
  "path": "./src/**/*.{ts,tsx}",
  "schema": "./sanity.config.ts",
  "generates": "./src/sanity/types/sanity.types.ts",
  "overloadClientMethods": true
}

Three fields matter here. path is the glob TypeGen scans for groq tagged template literals — point it at every file that runs queries. schema must resolve to the file that exports your Sanity config so the CLI can load all document types. generates is the output path; I keep it inside src/sanity/types/ so it sits near the query helpers that consume it. overloadClientMethods tells TypeGen to also patch sanityClient.fetch with per-query return types, which removes the need to cast results manually.

After writing the config, run generation once:

npx sanity@latest codegen generate

If everything resolves you will see sanity.types.ts appear. Commit it. 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 wrapped with the groq tagged template from groq or next-sanity. A plain string is invisible to the scanner.

// src/sanity/queries/post.ts
import { groq } from 'next-sanity'
import type { PostPageQueryResult } from '../types/sanity.types'
 
export const postPageQuery = groq`
  *[_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: import('next-sanity').SanityClient,
  slug: string
): Promise<PostPageQueryResult> {
  return client.fetch(postPageQuery, { slug })
}

When you annotate the import as PostPageQueryResult, 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@latest codegen generate
  env:
    SANITY_API_READ_TOKEN: ${{ secrets.SANITY_API_READ_TOKEN }}
 
- name: Check for uncommitted type changes
  run: |
    git diff --exit-code src/sanity/types/sanity.types.ts \
      || (echo "sanity.types.ts is out of date. Run 'npx sanity@latest codegen generate' locally and commit the result." && exit 1)
 
- name: TypeScript check
  run: npx tsc --noEmit

The SANITY_API_READ_TOKEN is required when your schema references documents that live behind dataset permissions. Without it the CLI falls back to the public API and may miss types for non-public document types. Add the token to your Actions secrets and pass it through; the codegen CLI picks it up from the environment.

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 codegen 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@latest codegen generate --watch).

Optional fields in arrays become (Type | null)[], not Type[]. Sanity's array items are always considered potentially sparse because editors can delete array items via the Studio. TypeGen reflects this honestly. You will need a .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 codegen 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 codegen generate --watch to your dev script so types regenerate on every schema file save:

// package.json scripts excerpt
"dev:studio": "sanity dev",
"dev:types": "sanity codegen generate --watch",
"dev": "concurrently \"next dev --turbopack\" \"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.

Related posts

All posts →