Sanity

Typed GROQ Queries in Next.js: A Practical Guide for Developers

Learn how to write typed GROQ queries in Next.js with Sanity. Boost DX, catch errors at compile time, and ship safer code with sanity-typegen and TypeScript.

June 26, 202612 min readMuhammad Zohaib Ramzan
Typed GROQ Queries in Next.js: A Practical Guide for Developers

If you've ever shipped a Next.js + Sanity project and watched a runtime error surface because a GROQ query returned a shape your TypeScript types didn't expect, you already know the pain. Typed GROQ queries close that gap entirely — bringing compile-time safety to your data-fetching layer and making your entire content pipeline more predictable, maintainable, and enjoyable to work with. In this guide, we'll walk through everything you need to go from raw, untyped client.fetch() calls to a fully type-safe GROQ workflow in a Next.js App Router project.

Why Typed GROQ Queries Matter

GROQ is expressive and powerful, but it's also a string — and strings don't carry type information. Without typed queries, every client.fetch() call returns any, which means you get no autocomplete on returned data, no compile-time errors when you rename a field in your Sanity schema, and silent runtime failures when a projection doesn't match what your component expects.

Typed GROQ queries solve all three. When your fetch calls are parameterized with TypeScript generics — and those generics are generated from your actual Sanity schema — you get full IntelliSense on query results, immediate errors when your schema and code drift apart, and confidence when refactoring because the compiler catches mismatches before your users do. The DX improvement is real: teams that adopt typed queries report fewer data-related bugs in production and faster onboarding for new developers.

Setting Up Sanity Client with TypeScript

Before you can type your queries, you need a properly configured Sanity client. Start by installing the official package:

npm install @sanity/client

Create a dedicated client file at 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',
})

In your Next.js App Router project, this file is imported directly in Server Components and Route Handlers — no API routes needed. The useCdn flag ensures cached responses in production while always hitting the live API during development. For preview drafts, create a second client with a token and perspective: 'previewDrafts' — and keep that token server-side only.

Using sanity-typegen for Query Types

sanity-typegen is the official Sanity tool for generating TypeScript types from your schema and your GROQ queries. It introspects your schema, extracts every query in your codebase, and emits a sanity.types.ts file with precise return types for each one.

Install it with npm install --save-dev @sanity/typegen, then add a sanity-typegen.json config at your project root:

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

Run codegen with npx sanity typegen generate. This command extracts your schema to schema.json, scans your source files for groq`...` tagged template literals, infers the return type of each query, and writes all types to sanity/types.ts. Add it to your package.json scripts so it's easy to re-run after schema changes. Then import generated types like this:

import type { AllPostsQueryResult } from '@/sanity/types'

const posts = await client.fetch<AllPostsQueryResult>(ALL_POSTS_QUERY)

Writing and Typing Complex GROQ Queries

With typegen in place, use the groq tagged template literal (from next-sanity) so typegen can find and parse your queries. Here's a complete typed fetch function:

import { groq } from 'next-sanity'
import type { PostBySlugQueryResult } from '@/sanity/types'

export const POST_BY_SLUG_QUERY = groq`
*[_type == "post" && slug.current == $slug][0] {
_id, title, slug, publishedAt, excerpt, body,
"author": author->{ name, image },
"category": category->{ title, slug }
}
`

export async function getPostBySlug(slug: string) {
return client.fetch<PostBySlugQueryResult>(
POST_BY_SLUG_QUERY,
{ slug },
{ next: { revalidate: 3600, tags: ['post', slug] } }
)
}

For even better IDE integration, prefer defineQuery from next-sanity, which wraps the tagged template and is the direction the Sanity ecosystem is moving:

import { defineQuery } from 'next-sanity'

export const ALL_POSTS_QUERY = defineQuery(`
*[_type == "post"] | order(publishedAt desc) {
_id, title, slug, excerpt, publishedAt,
"mainImage": mainImage.asset->url
}
`)

export const FEATURED_POSTS_QUERY = defineQuery(`
*[_type == "post" && featured == true] {
_id, title, excerpt,
"imageUrl": mainImage.asset->url,
"imageAlt": mainImage.alt,
"authorName": author->name,
"tags": tags[]
}
`)

Typegen will infer that imageUrl is string | null, tags is string[] | null, and so on — matching exactly what GROQ can return.

Handling References and Arrays

One of the trickiest parts of typing GROQ queries is dealing with references and arrays of references. GROQ's -> dereference operator expands a reference inline, and typegen understands this. For a single reference, the generated type will be an object with the projected fields — not a raw Reference type.

// Single reference expansion
export const POST_WITH_AUTHOR_QUERY = defineQuery(`
*[_type == "post"][0] {
title,
"author": author-> {
name,
"avatarUrl": image.asset->url,
bio
}
}
`)

// Array of references — []-> dereferences every item
export const POST_WITH_RELATED_QUERY = defineQuery(`
*[_type == "post" && slug.current == $slug][0] {
title,
"relatedPosts": relatedPosts[]-> {
_id, title, slug, excerpt
}
}
`)

GROQ returns null for missing references, and typegen reflects this — a nested parentCategory will be typed as { title: string; slug: Slug } | null. Always handle the null case in your components — don't assert with ! unless you've migrated all existing documents.

Caching Strategies for Typed Queries in App Router

Next.js App Router's fetch caching integrates directly with the Sanity client via the next option. Typed queries work seamlessly with all caching strategies. The type safety carries through regardless of the caching option used.

// Static generation with time-based revalidation
export async function getAllPosts() {
return client.fetch<AllPostsQueryResult>(
ALL_POSTS_QUERY, {},
{ next: { revalidate: 3600 } }
)
}

// On-demand revalidation with tags
export async function getPost(slug: string) {
return client.fetch<PostBySlugQueryResult>(
POST_BY_SLUG_QUERY, { slug },
{ next: { tags: [`post:${slug}`, 'post'] } }
)
}

// No caching for preview mode
export async function getPreviewPost(slug: string) {
return previewClient.fetch<PostBySlugQueryResult>(
POST_BY_SLUG_QUERY, { slug },
{ cache: 'no-store' }
)
}

In your Sanity webhook handler (a Route Handler at app/api/revalidate/route.ts), call revalidateTag(`post:${slug}`) to trigger on-demand revalidation whenever content is published in Sanity.

Common Mistakes

Even experienced developers fall into these traps when adopting typed GROQ queries:

  • Using any as the generic type. client.fetch<any>(query) defeats the entire purpose. Use unknown and narrow it if you don't have a generated type yet.
  • Not re-running typegen after schema changes. Your generated types go stale the moment you add, rename, or remove a field. Make typegen part of your CI pipeline.
  • Mismatched projections. If your GROQ projection selects "imageUrl": mainImage.asset->url but your component expects image.url, you'll get a type error. Don't suppress it with a cast — fix the projection.
  • Forgetting the groq tag or defineQuery wrapper. Typegen only finds queries wrapped in these — plain string queries are invisible to it.
  • Ignoring nullable fields. GROQ returns null for missing values. If typegen says a field is string | null, handle both cases — don't assert with ! unless you're certain.
  • Hardcoding API versions. Pin your apiVersion to a specific date and update it deliberately — changing it can alter query behavior.

Best Practices

  • Co-locate queries with their consumers. Keep your defineQuery calls in the same file (or a sibling file) as the component or page that uses them.
  • Run typegen in CI. Add npm run typegen as a CI step and fail the build if the generated file has uncommitted changes.
  • Use perspective: 'published' in production. Avoid accidentally serving draft content by being explicit about the perspective on your production client.
  • Prefer defineQuery over the raw groq tag for better tooling support and forward compatibility.
  • Keep projections minimal. Only select the fields your component actually needs. Smaller payloads mean faster pages and simpler types.
  • Validate at the boundary. Consider using Zod to validate data at the edge — especially for user-generated content or external integrations — even when types are generated.
  • Document complex queries. Add a comment above non-obvious GROQ queries explaining what they return and why the projection is shaped the way it is.

FAQ

Do I need sanity-typegen or can I write types manually?

You can write types manually, but it's error-prone and doesn't scale. Every time your schema changes, you'd need to update your types by hand. sanity-typegen automates this and derives types directly from your schema, so they're always accurate.

Does typed GROQ work with the Next.js Pages Router?

Yes. The client.fetch<T>() generic pattern works anywhere TypeScript runs. The caching options (next: { revalidate, tags }) are specific to the App Router's extended fetch, but the type safety itself is framework-agnostic.

What happens if my GROQ query returns a shape that doesn't match the generated type?

This usually means your projection has drifted from what typegen expects. Re-run npx sanity typegen generate to refresh the types. If the mismatch persists, check that your query is wrapped in groq`...` or defineQuery(...) so typegen can find it.

Can I use typed queries with Sanity's real-time listener?

The client.listen() method returns a stream of mutation events, not query results, so the typing story is different. You can type the result.result field using your document types (e.g., SanityDocument<Post>), but full query-level typing isn't available for live listeners yet.

How do I handle optional fields that may not exist in older documents?

GROQ returns null for missing fields, and typegen reflects this with union types like string | null. Use optional chaining (post.newField?.value) and provide sensible defaults. Avoid non-null assertions unless you've migrated all existing documents to include the field.

Conclusion

Typed GROQ queries are one of the highest-leverage improvements you can make to a Next.js + Sanity project. By combining @sanity/client TypeScript generics with sanity-typegen codegen, you get a data-fetching layer that's as type-safe as your database schema — with zero runtime overhead. You catch mismatches at compile time, your editor gives you accurate autocomplete, and your team can refactor with confidence.

Start by installing @sanity/typegen, wrapping your queries in defineQuery, and running codegen. Once you see the generated types light up your IDE, there's no going back. Type safety isn't just a nice-to-have — in a content-driven Next.js app, it's the foundation of a maintainable codebase.