Building with AI

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.

Val OvinnikovVal Ovinnikov3 min read
A single glowing pipe of light carrying typed data from a database vault into neat React panels, everything colour-matched and aligned.

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.

packages/service/src/queries/posts.ts
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:

packages/service/src/index.ts -->
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.

2 min

Rendering Portable Text in Next.js 16

Portable Text is just JSON until you render it. How I mapped Sanity's block format to React components — headings, links, and real code blocks — in the App Router.

building with ai
3 min

Designing the Content Model by Conversation

I didn't hand-write the Sanity schema — I argued about it with an agent until the shapes were right. How a conversation became a typed content model with real constraints.

building with ai
3 min

Statically Rendered, Instantly Fresh

Static pages that update the moment I publish. How a Sanity webhook triggers on-demand revalidation so a fully static Next.js site never serves stale content.

building with ai