A Typed Service Layer with groqd: Queries the Agent Can't Get Wrong
One place owns every query, and it's typed end to end. How a groqd service package turns GROQ strings into functions an agent — and the compiler — can trust.

A GROQ query is a string, and strings lie. They drift from the schema, they return any, and an agent editing one three months later has no way to know it broke a page until the page is blank in production. The fix on this blog is a single package that owns every query and makes each one typed end to end. This is how packages/service became the layer the agent — and the compiler — can trust. Part of the Building with AI series.
One package owns the data
The rule from CLAUDE.md is blunt: components never import the Sanity client. They import functions from packages/service. That single boundary means there's exactly one place queries live, one place they're typed, and one place to test them. When an agent needs a new field on the post page, it has an obvious and only destination.
groqd makes the query defend itself
groqd builds the GROQ string and its return type at the same time, from the same code. You describe the projection once; you get the query text and a runtime-validated, statically-typed result. There's no second hand-written interface to fall out of sync.
1import { q } from "groqd";
2
3export const postCardsQuery = q("*")
4 .filterByType("blog_post")
5 .order("publishedAt desc")
6 .grab$({
7 title: q.string(),
8 slug: q.slug("slug"),
9 excerpt: q.string(),
10 publishedAt: q.date(),
11 author: q("author").deref().grab$({ name: q.string() }),
12 });The grab$ on the highlighted line is the whole point: it parses the response, so a field that's missing or the wrong type throws here, at the seam, with a clear message — not as undefined is not an object deep in a React tree. The dereferenced author shows how references collapse into typed nested data without a second round trip in the component.
A thin, typed front door
Callers never see GROQ. They see a function with a real return type, which is all a page component — or an agent writing one — should have to reason about:
1import { runQuery } from "./client";
2import { postCardsQuery } from "./queries/posts";
3
4export async function getPostCards() {
5 return runQuery(postCardsQuery); // Promise<PostCard[]>, fully typed
6}Now the App Router page is boring in the best way: const posts = await getPostCards() and the type is known. Rendering that typed data — including the Portable Text body — is the next post, Rendering Portable Text in Next.js 16.
Why the agent can't get it wrong
Three things stack up to make bad queries hard to ship. The projection is typed, so a typo in a field name won't compile. The response is parsed, so a schema change that the query forgot about fails loudly at the boundary. And the boundary itself means there's nowhere else to write a query, so review always looks in one folder.
Types catch the mistake you'll make; a parsed boundary catches the mistake the data will make.
Because the queries are plain functions, they're also trivially testable, and CLAUDE.md requires a test in the same PR as any new one. That test-with-the-query habit is part of a wider discipline — Tests the Agents Can't Skip — and it's what lets me accept an agent's query without re-deriving the GROQ by hand.
Make every query a typed function in one package, and a whole class of silent breakage disappears. The service layer isn't glamorous, but it's the difference between an agent that edits data access safely and one that guesses. This is the same schema-to-types thread pulled tight; the full arc is From Sanity Schema to Typed React.
