End-to-end type safety for Sanity GROQ queries (Generated by Leonardo)

End-to-end type safety for Sanity GROQ queries

GROQ is a versatile language to query your data from Sanity's Content Lake. That versatility often comes at the cost of type safety, but it doesn't have to! This article explores how you can remain type-safe with Sanity from the beginning to the end.

TC

Tristan Chin

Unless you've worked with GraphQL before, Sanity's GROQ query language can be intimidating at first. It's not your traditional SQL or NoSQL queries and it takes some time to grasp. However, after working with it for while, you may find that GROQ is pretty powerful and versatile when it comes to querying your content lake. In fact, it can sometimes feel a bit too versatile.

Being a heavy TypeScript user, one of the first concerns I had with GROQ was it's lack of type-safety. That's not surprising, though, as querying with GROQ comes down to the same concerns you should have when querying a REST API. Sure, you can cast your responses type to something else, rather than working with any or unknown, but all you're doing is lying to TypeScript (and yourself) about what's actually coming out of an external response body. That's where popular schema validators like Zod or Joi come in: they let your safely type response bodies, so you're certain that the type you're working with is actually what you're expecting. If the response changes for some reason, you'll get an error, preventing your code from going further with the assumption that it got what it's expecting.

If you're like me and you're very strict when it comes to type safety and any gives you anxiety, then this post is for you. In this article, we'll be going over how to ensure end-to-end type safety for GROQ queries. By "end-to-end", I mean from the moment you create your documents' schemas to the moment you retrieve your data in your app. We'll be using a combination of the following tools to achieve this:

  • Groqd: a type-safe GROQ query builder, built on top of Zod. This library will do most of the heavy lifting in achieving our goal.
  • Zod: a schema validator to validate (and type) incoming data from your queries.
  • Sanity TypeGen: Official tool for creating TypeScript types from your schemas.
  • ts-to-zod: Creates Zod schemas from TypeScript types.

At the end of this walkthrough, you can expect your workflow to look something like this:

  • 1. Create (or update) your Sanity document schema.
  • 2. Run a script to generate Zod schemas from your document schema.
  • 3. Create a typed selection (projection) for Groqd query builders, using the generated Zod schemas.
  • 4. Create a Groqd query, either from scratch or by building on top of another query.
  • 5. Use your query and selection to get type-safe data from your content lake.

Why should you use this pattern?

Strongly typed

If you ask any of my co-workers, they'll probably tell you I'm a bit of a typing freak when it comes to type strictness. I believe that strongly typing over loosely typing (and any) wins in the long run. Imagine coming back to a project after 3 months of working on another and misusing a function that accepts a string instead of a union of string literals, because you forgot there were truly only 3 valid values.

I also like to setup workflows in a way that they are less prone to human error. The following pattern mostly aims at reducing human error, but also enhances your developer experience with strict type checks. While it is not 100% fool-proof, I believe it is a step further than using pure GROQ queries or groqd alone.

Battle-tested

The method I'm about to present to you is a method that I have perfected over the span of over 5 months of working on both my own personal website (you're reading this on) and at the company I work at. Even my own website does not take full advantage of this, because it has remnants of old versions of the pattern I didn't take the time of completely refactoring. I'm showing you the culmination of this method!

Flexible

When type strictness increases in your project, complexity often grows with it. While the majority of cases will be pretty straightforward and feel almost automatic, there are some more complex cases which we'll explore in this post. While there are often ways to maintain type strictness if you've got the patience, this technique works well if you feel like being more loose for other, more complex parts of it. So you can mix type strictness when it's easy and type looseness when it's too complicated.

Source Code Repository

The code shown in this post can be viewed on GitHub.

Setup

Although most of you will probably already have a project setup, I'll still go over starting project from scratch for clarity. This will be quick, but feel free to skip ahead to the next section if you already have one setup.

Create a NextJS project

We'll be using NextJS 14 with app directory for this tutorial. We'll use the next-sanity package for integration with Sanity. Although I'm fairly sure that most of the concepts that will follow are framework agnostic, I've never built with Sanity outside of NextJS.

Run the following command to create your project. The answers don't matter much for the purposes of this tutorial, but it's recommended to use the App directory.

npx create-next-app@latest

✔ What is your project named? … sanity-type-safe-groq
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … No

Install Sanity

Sanity's CLI is full of helpful commands we'll use in this tutorial. We'll use the sanity init command to scaffold the integration of Sanity into our project.

Most libraries will often tell you to install CLIs globally (npm i -g @sanity/cli) or always run the latest with NPX (npx sanity@latest init). However, I like to stay away from global dependencies and install the CLI as a dev-dependency directly in the project. NPM scripts and NPX calls will use that version of the CLI instead of the globally installed one. That way, there will be no confusion about versions if someone else clones your repo (or if you clone it on another machine). You may proceed however you like to setup Sanity, as it shouldn't affect the rest of this tutorial

npm i -D @sanity/cli
npx sanity init

? Select project to use Create new project
? Your project name: sanity-type-safe-groq
Your content will be stored in a dataset that can be public or private, depending on
whether you want to query your content with or without authentication.
The default dataset configuration has a public dataset named "production".
? Use the default dataset configuration? Yes
✔ Creating dataset
? Would you like to add configuration files for a Sanity project in this Next.js folder? Yes
? Do you want to use TypeScript? Yes
? Would you like an embedded Sanity Studio? Yes
? What route do you want to use for the Studio? /studio
? Select project template to use Clean project with no predefined schema types
? Would you like to add the project ID and dataset to your .env file? Yes

Additional configuration

Normally, just the basic configuration doesn't fit my needs. For example, I like to set the client's useCdn option to process.env.NEXT_PUBLIC_VERCEL_ENV === "production" instead of always true. However, this goes out of scope of this article. You can always look at the repo I made for this post if you wish to view the full config.

Create your document schema

Let's create two simple schemas: a post and a category schema. These two are pretty minimal, but should cover the main cases you'll encounter when working on your project:

  • Simple fields like strings
  • Arrays
  • References
  • Portable text with annotations, decorators and custom blocks.
  • category.tsx
  • post.tsx
  • sanity.config.ts
import { defineType } from "sanity";

export default defineType({
  name: "category",
  type: "document",
  fields: [
    {
      name: "title",
      type: "string",
      validation: (Rule) => Rule.required(),
    },
    {
      name: "slug",
      type: "slug",
      options: {
        source: "title",
      },
      validation: (Rule) => Rule.required(),
    },
  ],
});

Generate types from Sanity schemas

At the time of writing this post, Sanity recently introduced an experimental feature: TypeGen. This feature, accessible through the Sanity CLI, is able to generate TypeScript types from your Sanity schemas. In this section, we'll see how to create a simple NPM script that generates these types.

Start by adding a sanity-typegen.json file to configure the tool.

  • sanity-typegen.json
{
  "path": "'./src/sanity/schemas/**/*.{ts,tsx}'",
  "schema": "./src/sanity/sanity.schema.json",
  "generates": "./src/sanity/sanity.types.ts"
}
  • The path property is where Sanity can find your schemas (and GROQ queries written with Sanity's groq function, but we won't be using that).
  • The schema property tells Sanity where to find your JSON Schema representation of your Sanity types. We will generate this file with another Sanity CLI tool: extract.
  • Finally, the generates property tells Sanity where to output the TypeScript types to.

We are now ready to generate our types! Add the following NPM script to your package.json .

  • package.json
{
  // ...
  "scripts": {
    // ...
        "sanity:typegen": "sanity schema extract --path './src/sanity/sanity.schema.json' --enforce-required-fields --workspace=production && sanity typegen generate"
  }
  // ...
}

This script will do 2 things:

  • sanity schema extract extracts a JSON Schema specification to the schema path we defined earlier. It also enforces required fields, i.e. wherever we use validation: (Rule) => Rule.required(). The last parameter, workspace, specifies which workspace to extract this information from. If you only have one workspace or all of your workspaces use the same schema, you may be able to run the command without it or leave it at production.
  • sanity typegen generate generates the TypeScript types using the config we created earlier.

Now run npm run sanity:typegen and observe your types get auto-generated!

 npm run sanity:typegen

✔ Extracted schema, with enforced required fields
✔ Generated TypeScript types for 13 schema types and 0 GROQ queries in 0 files into: ./src/sanity/sanity.types.ts

Don't get too excited yet! In practice, we won't be using these types directly. We'll use types we create from groqd selections, which will come later.

Generate Zod schemas from types

Now that we've got types, let's turn them into Zod schemas. For this, we'll need to install ts-to-zod to do just that. Since we'll only use this in NPM scripts, it's safe to install as dev-dependency. While we're in the terminal, you might as well install other dependencies we'll need shortly after:

  • tsx (TypeScript execute) allows us to easily run TypeScript files, similar to ts-node.
  • groqd will be the main tool we'll be using for building groq queries.
npm i -D ts-to-zod tsx
npm i groqd

Add a new NPM script to run the sanity:typegen script we just created, then use ts-to-zod to generate Zod schemas. You don't need to prepend the script with sanity:typegen, but it's just there as convenience. You could also append the ts-to-zod part to sanity:typegen if you want to keep it all in one script!

  • package.json
{
  // ...
  "scripts": {
    // ...
    "sanity:typegen": "sanity schema extract --path './src/sanity/sanity.schema.json' --enforce-required-fields --workspace=production && sanity typegen generate",
    "sanity:schemas": "npm run sanity:typegen && ts-to-zod src/sanity/sanity.types.ts src/sanity/sanity.schemas.ts"
  }
  // ...
}

Run npm run sanity:schemas and take a look at your new Zod schemas!

Now, we don't want to use Zod directly for these schemas. We want to use groqd's q variable instead. This variable is just a wrapper around Zod, so your schemas will work just as much! We just need to change the import of the generated file to import q from groqd instead of z from Zod. The way I did this is with a small TS script I run at the end of the sanity:schemas script we just created.

  • scripts/sanity-schemas.ts
  • package.json
import fs from "fs/promises";
import path from "path";

(async () => {
  // You might need to update this if your file structure is not the same as mine
  const schemaFile = path.join(
    __dirname,
    "..",
    "src",
    "sanity",
    "sanity.schemas.ts"
  );
  const schemaContent = await fs.readFile(schemaFile, "utf-8");
  const groqdImport = 'import { q as z } from "groqd";';
  const groqdImportRegex = /import { z } from "zod";/;
  const newSchemaContent = schemaContent.replace(groqdImportRegex, groqdImport);
  await fs.writeFile(schemaFile, newSchemaContent);
  console.log("✔ Updated sanity.schemas.ts with groqd import");
})();

Now, when you run the sanity:schemas script, 4 things will happen:

  • You Sanity schema will be extracted into a JSON Schema
  • Sanity will read this JSON Schema and create TypeScript types from it
  • ts-to-zod will read these types and create Zod schemas from it
  • the TS script we just created will update the import from the generated Zod schemas file to use groqd instead.

Create typed selections

Selections are just the projection part of your traditional GROQ queries, but they're used by groqd to type the result coming out of queries. Usually, with groqd, you would specify the schema for each field manually, but we'll use the auto-generated schemas from the previous step instead.

Category Details

Let's start with the category selection. I like to call it CategoryDetails and not Category, because Category is already taken from the auto-generated types and our selections won't match it exactly (some fields missing, some fields added and some renamed fields). However, you're free to name it however you like!

First, let's look at what building this selection would look like using groqd only, and not any of our schemas we just spent the beginning of this post generating. I want to show you this, because both versions of the categoryDetailsSelection translate to the same GROQ and you can mix them together if one way is easier than the other.

  • src/sanity/selections/category-details.ts
import { q, type Selection, type TypeFromSelection } from "groqd";

export const categoryDetailsSelection = {
  type: ["_type", q.literal("category")],
  id: ["_id", q.string()],
  title: q.string(),
  slug: q.slug("slug"), // Equivalent to: ["slug.current", q.string()]
} satisfies Selection;

/**
    type CategoryDetails = {
        type: "category";
        id: string;
        title: string;
        slug: string;
    }
 */
export type CategoryDetails = TypeFromSelection<
  typeof categoryDetailsSelection
>;

In the above snippet, we make use of groqd only, using the q exported variable. We then infer it's type, similar to how you would do this with z.infer from Zod.

Now let's see what this looks like with our auto-generated schemas:

  • category-details.ts
import { q, type Selection, type TypeFromSelection } from "groqd";
import { categorySchema } from "../sanity.schemas";

export const categoryDetailsSelection = {
  type: ["_type", categorySchema.shape._type],
  id: ["_id", categorySchema.shape._id],
  title: categorySchema.shape.title,
  slug: ["slug.current", categorySchema.shape.slug.shape.current],
} satisfies Selection;

/**
    type CategoryDetails = {
        type: "category";
        id: string;
        title: string;
        slug: string;
    }
 */
export type CategoryDetails = TypeFromSelection<
  typeof categoryDetailsSelection
>;

In this snippet, we make the use of our previously generated schemas to build our selection. It's a little bit more work length-wise, but you don't really need to think about your schema anymore. Plus, if you change your category schema's slug field to something else, like a plain string, then generate your schemas, you'll get an error here, but you wouldn't with the other way (until you actually try to make a query).

Post Details

Now, let's make the selection for posts. This one will be a bit more complicated than the category selection, especially when it comes to portable text. This time, we won't look at the comparison with using groqd alone, but I can assure you, it is much more complicated when you don't have schemas to spread like we're about to do (and is kind of the reason I don't want to spend too much time building it!).

  • post-details.ts
import {
  nullToUndefined,
  q,
  type InferType,
  type Selection,
  type TypeFromSelection,
} from "groqd";
import { postSchema } from "../sanity.schemas";
import { categoryDetailsSelection, type CategoryDetails } from "./category-details";

const postBodyBlock = postSchema.shape.body.element.options[0];
export type PostBodyBlock = InferType<typeof postBodyBlock>;

// If there were more than 1 mark def, we would need element.shape.options[n] instead
const postBodyInternalLink = postBodyBlock.shape.markDefs.unwrap().element;
export type PostBodyInternalLink = Omit<InferType<typeof postBodyInternalLink>, "reference"> & {
  reference: CategoryDetails;
};

const postBodyAlert = postSchema.shape.body.element.options[1];
export type PostBodyAlert = InferType<typeof postBodyAlert>;

export const postDetailsSelection = {
  type: ["_type", postSchema.shape._type],
  id: ["_id", postSchema.shape._id],
  title: postSchema.shape.title,
  slug: ["slug.current", postSchema.shape.slug.shape.current],
  categories: q("categories").filter().deref().grab$(categoryDetailsSelection),
  keywords: postSchema.shape.keywords,
  // Usually, we could have used postSchema.shape.body, but we need to deref internalLinks. 
  body: q("body")
    .filter()
    .select({
      "_type == 'block'": nullToUndefined({
        ...postBodyBlock.shape,
        markDefs: q("markDefs")
          .filter()
          .select({
            "_type == 'internalLink'": nullToUndefined({
              ...postBodyInternalLink.shape,
              reference: q("@.reference").deref().select({
                "_type == 'category'": categoryDetailsSelection,
              }),
            }),
            default: {
              _key: q.string(),
              _type: ["'unknown'", q.literal("unknown")],
              unknownType: ["_type", q.string()],
            },
          }),
      }),
      "_type == 'alert'": nullToUndefined(postBodyAlert.shape),
      default: {
        _key: q.string(),
        _type: ["'unknown'", q.literal("unknown")],
        unknownType: ["_type", q.string()],
      },
    }),
} satisfies Selection;

export type PostDetails = TypeFromSelection<typeof postDetailsSelection>;

If it weren't for the fact that we want to dereference internal links, we could have simply used body: postSchema.shape.body, just like the other fields. However, because of this special case, we need to dig deeper and use the select method, which you can probably infer it's use by looking at it: whenever you encounter a body element of _type 'block', use the given selection.

We also need to specify a default case. Because of the way groqd creates a select in GROQ, a case that wouldn't match would become null by default, which translates to a Record<string, never> and really kills our typings. That's why, when making queries with select, you should always add common fields to the default case (like _key and type in this case). I like to include unknownType for debugging purposes during development.

In my experience, whenever you're working with an array of objects, I found it's always better to use q("fieldName").filter().select({...}) rather than a q.array(q.object({...})), because you can't make sub-queries like q("markDefs") inside of the latter. This translates to fieldName[] { ...( select({...}) ) } in GROQ.

Lastly, we use our previously built selection to dereference each category in full, by giving the entire selection as parameter to grab$. If you find you never need ALL the fields of a category here, you could just specify a selection manually like so:

{
  categories: q("categories").filter().deref().grab$({
    slug: categoryDetailsSelection.slug
  })
}

You could also define a handy utility for doing common picks like this. (which will become super useful in queries, so I highly recommend you make a file for it!)

  • src/sanity/selections/utils/pick.ts
  • category-details.ts
import type { Selection } from "groqd";

export const pick = <S extends Selection, P extends (keyof S)[]>(
  selection: S,
  fields: P,
) => {
  return fields.reduce(
    (acc, key) => {
      acc[key] = selection[key];
      return acc;
    },
    {} as Pick<S, P[number]>,
  );
};

Create queries

Now that we've got our type-safe selections made, the hardest part is over! We can now start building our queries. Each query file will be composed of two parts:

  • A query maker: this will allow us later to compose queries together, making each one dead simple, because each query can build on top of another. In my projects, using Prettier, query makers rarely go beyond 2 lines!
  • A query runner: this will be the functions we'll use in our pages to actually query our content.

Utilities

Before we jump into queries, I'd like to show you some utilities. When using groqd, it's important to understand that it only types the projection and slices (1 result or an array of results). Everything else, especially filters, are just strings that the library will use as-is into the final GROQ query. While it makes sense that filters can't be typed, most likely due to the versatility of GROQ, it would still be nice to have some helpers to reduce human error.

The following utilities are completely optional, they'll only improve the DX. In the end, they're just string utilities, and not very complex ones. However, I will be using them in the following sections. I'll only include the ones we'll be using, but you can find more like these that I use on my website here.

  • s.ts
  • and.ts
  • or.ts
import type { BaseQuery } from "groqd";

export type QueryLike = BaseQuery<any> | string | number | boolean;

export type Defined<T> = Exclude<T, undefined | null | "">;
export const isDefined = <T>(value: T): value is Defined<T> =>
  value !== undefined && value !== null && value !== "";

/**
 * Converts a primitive or a subquery to a string or an array of strings
 *
 * @param value A QueryLike or an array of QueryLike values
 * @returns A string or an array of strings
 */
export const qS = <
  T extends QueryLike | QueryLike[],
  R = T extends QueryLike[] ? string[] : string,
>(
  value: T
): R => {
  if (Array.isArray(value)) {
    return value.map((v) => qS(v)).filter(isDefined) as R;
  }
  if (typeof value === "string") return value as R;
  if (typeof value === "number") return `${value}` as R;
  if (typeof value === "boolean") return `${value}` as R;
  return value.query as R;
};

Query makers

Query makers are responsible for building every part of a query, except the projection. This specific distinction is what makes a query maker re-usable, because it doesn't rely on a projection (which usually marks the end of a query).

Let's make two query makers:

  • getCategories: gets all categories.
  • findCategoryBySlug: gets a single category by slug
  • getCategories.ts
  • findCategoryBySlug.ts
import { qAnd } from "@/sanity/filters/and";
import { qType } from "@/sanity/filters/type";
import { q } from "groqd";

export const makeGetCategoriesQuery = (filter?: string) =>
  q("*").filter(qAnd(qType("category"), filter));

There are 2 things to notice:

  • First, in most cases, your query makers should always accept an optional filter that can be optionally applied to it. This is capability is what allows us to re-use the getCategories query maker into the findCategoryBySlug query maker.
  • Second, we make use of parametrized queries ($slug), rather than feeding it as function parameter. This makes queries consistent and predictable, but query runners will need to provide these parameters later on.

Let's do the same for posts

  • getPosts.ts
  • getPostBySlug.ts
  • getPostsByCategoryId.ts
import { qAnd } from "@/sanity/filters/and";
import { qType } from "@/sanity/filters/type";
import { q } from "groqd";

export const makeGetPostsQuery = (filter?: string) =>
  q("*").filter(qAnd(qType("post"), filter));

Query runners

Query runners are responsible for adding the projection and slice part to query makers and run them to retrieve content from Sanity. Each query runner's function will take parameters for parameterized queries and a selection.

Setting up the groqd runner

In order to get typed and validated results from our queries, we'll need to make a safe query runner. This is provided by groqd.

  • src/sanity/queries/runQuery.ts
import { makeSafeQueryRunner } from "groqd";
import type { FilteredResponseQueryOptions } from "next-sanity";
import { client } from "../lib/client";

export type RunQueryParams = Record<string, unknown>;
export type RunQueryOptions = FilteredResponseQueryOptions;

export const runQuery = makeSafeQueryRunner(
  async (query, params?: RunQueryParams, options?: RunQueryOptions) => {
    return client.fetch(query, params, options);
  },
);

This is the most simple groqd query runner you'll need for standard use-cases. However, you may need to add additional logic for more complex cases, like I do to support Sanity's Presentation mode.

Category and post runners

We can add the query runners for each queries we made previously in their same respective files.

  • getCategories.ts
  • findCategoryBySlug.ts
  • getPosts.ts
  • findPostBySlug.ts
  • getPostsByCategoryId.ts
import { qAnd } from "@/sanity/filters/and";
import { qType } from "@/sanity/filters/type";
import { q, type Selection } from "groqd";
import { runQuery } from "../runQuery";

export const makeGetCategoriesQuery = (filter?: string) =>
  q("*").filter(qAnd(qType("category"), filter));

export const getCategories = async <S extends Selection>(selection: S) =>
  runQuery(
    makeGetCategoriesQuery().grab$(selection),
    {},
    {
      next: { tags: ["category"] },
    },
  );

While it may sound redundant to specify a selection every time you use a query runner, the goal is to be able to query only what you need without creating multiple runners for each selection variations. You could just remove this parameter and always use the complete selection, but then you'd be over-querying most of the time.

Query your content lake

We're now all set to run our type-safe queries! Remember to use the pick utility we created earlier to query only what you need, but you can always just pass in the whole selection if over-querying is not your concern.

  • page.tsx
  • /category/[slug]/page.tsx
  • /post/[slug]/page.tsx
  • post-body.tsx
import { getCategories } from "@/sanity/queries/category/getCategories";
import { getPosts } from "@/sanity/queries/post/getPosts";
import { categoryDetailsSelection } from "@/sanity/selections/category-details";
import { postDetailsSelection } from "@/sanity/selections/post-details";
import { pick } from "@/sanity/selections/utils/pick";
import Link from "next/link";

const HomePage = async () => {
  const [posts, categories] = await Promise.all([
    getPosts(pick(postDetailsSelection, ["title", "slug"])),
    getCategories(pick(categoryDetailsSelection, ["title", "slug"])),
  ]);

  return (
    <div className="flex flex-col items-center max-w-4xl mx-auto p-4">
      <ul className="flex justify-center gap-4">
        {categories.map((category) => (
          <li key={category.slug}>
            <Link
              href={`/category/${category.slug}`}
              className="hover:underline"
            >
              {category.title}
            </Link>
          </li>
        ))}
      </ul>
      <hr className="my-4 w-full border-t border-gray-300" />
      <ul className="flex flex-col gap-4">
        {posts.map((post) => (
          <li key={post.slug} className="before:content-['>'] before:mr-4">
            <Link href={`/post/${post.slug}`} className="hover:underline">
              {post.title}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default HomePage;

Conclusion

In this post, we've seen how to create types and Zod schemas from your Sanity schemas, how to use these Zod schemas to create selections and how to make and run type-safe GROQ queries. No more type casting and/or using any! Hopefully, this post will have helped you improving your developer experience with GROQ and improve your type strictness!

Extras

Here are a couple more tips that go beyond end-to-end type safety for GROQ queries.

Print raw GROQ from groqd queries

If you want to debug the resulting query made by groqd and test it in the Studio's Vision tool, this script will do just that.

Start by installing groqfmt-nodejs. This tool will print pretty GROQ queries.

npm i -D groqfmt-nodejs

Add a TS script that will convert a groqd query into a pretty GROQ query and output the result to a file.

  • groq-preview.ts
  • package.json
  • npm run groq:preview
import { makeFindPostBySlugQuery } from "@/sanity/queries/post/findPostBySlug";
import { postDetailsSelection } from "@/sanity/selections/post-details";
import { pick } from "@/sanity/selections/utils/pick";
import { existsSync } from "fs";
import fs from "fs/promises";
import { format } from "groqfmt-nodejs";
import path from "path";

(async () => {
  try {
    const queryFile = path.join(__dirname, "sandbox.groq");
    if (!existsSync(queryFile)) {
      await fs.writeFile(
        queryFile,
        `*[_type == "post"]{ title, slug }`,
        "utf-8"
      );
      console.info("sandbox.groq created");
    }

    const query = makeFindPostBySlugQuery()
      .grab$(pick(postDetailsSelection, ["title", "slug"]))
      .slice(0)
      .nullable();

    const queryFormatted = format(query.query);
    await fs.writeFile(queryFile, queryFormatted, "utf-8");
  } catch (error) {
    console.error(error);
  }
})();

Revalidation Endpoint

You've probably noticed, when building our query runners, that I specified the next.tags. This is useful in NextJS projects, because it caches results by default. So you need a way to revalidate queries when content changes. There are many revalidation strategies, but I prefer to use tag-based revalidation.

To setup your revalidation endpoint, let's start by creating the webhook query schema. You can use this query inside the groq-preview file we created in the last section to get the inputs for the webhook on your Sanity dashboard.

  • src/app/api/sanity/revalidate/query.ts
import { qType } from "@/sanity/filters/type";
import { categoryDetailsSelection } from "@/sanity/selections/category-details";
import { postDetailsSelection } from "@/sanity/selections/post-details";
import { pick } from "@/sanity/selections/utils/pick";
import { nullToUndefined, q } from "groqd";

// Use this as the webhook filter value in Sanity's dashboard.
export const webhookFilter = "_type in ['post', 'category']";

// Put this query in the groq-preview.ts script to view everything you'll need to provide the webhook with the information it needs.
export const webhookBodyQuery = q("*")
  .filter(webhookFilter)
  .slice(0)
  .select({
    [qType("post")]: nullToUndefined(
      pick(postDetailsSelection, ["type", "slug", "categories"])
    ),
    [qType("category")]: nullToUndefined(
      pick(categoryDetailsSelection, ["type", "slug", "id"])
    ),
    default: {
      type: ["'unknown'", q.literal("unknown")],
      unknownType: ["_type", q.string()],
    },
  });

Then, add an API route that you can give to your Sanity webhook (in the dashboard). This is a pretty basic revalidation logic, so you might need to tweak it depending on your project's usage.

  • src/app/api/sanity/revalidate/route.ts
import { useCdn } from "@/sanity/lib/client";
import { parseBody } from "next-sanity/webhook";
import { revalidateTag } from "next/cache";
import { NextResponse, type NextRequest } from "next/server";
import { webhookBodyQuery } from "./query";

const webhookBodySchema = webhookBodyQuery.schema;

export const POST = async (req: NextRequest) => {
  try {
    const { isValidSignature, body } = await parseBody<unknown>(
      req,
      process.env.SANITY_REVALIDATE_SECRET,
      true
    );

    if (!isValidSignature) {
      const message = "Invalid signature";
      return new Response(JSON.stringify({ message, isValidSignature, body }), {
        status: 401,
      });
    }

    const parseResult = webhookBodySchema.safeParse(body);
    if (!parseResult.success) {
      const message = "Invalid body";
      return new Response(JSON.stringify({ message, isValidSignature, body }), {
        status: 400,
      });
    }

    if (useCdn) {
      console.warn(
        "Sanity revalidate webhook called, but the app is currently using Sanity's CDN. Revalidation may not work as expected."
      );
    }

    const data = parseResult.data;
    const extraData: Record<string, unknown> = {};
    const tags = new Set<string>();

    const revalidate = (tag: string) => {
      if (!tag || tags.has(tag)) return;
      tags.add(tag);
      revalidateTag(tag);
    };

    revalidate(data.type);

    switch (data.type) {
      case "post":
        revalidate("post");
        revalidate(`post-slug-${data.slug}`);
        data.categories.forEach((category) => {
            revalidate(`post-categoryId-${category.id}`);
        })
        break;
      case "category":
        revalidate("category");
        revalidate(`category-id-${data.id}`);
        revalidate(`category-slug-${data.slug}`);
        break;
    }

    const payload = {
      revalidatedTags: Array.from(tags),
      data,
      extraData,
    } as const;

    return NextResponse.json(payload);
  } catch (err) {
    console.error(err);
    if (err instanceof Error) {
      return new Response(err.message, { status: 500 });
    }
    return new Response("An unknown error occurred", { status: 500 });
  }
};

Sliced queries

We haven't really touched slices. Slices allow you to limit and offset the result items returned from a query. You can use this utility function to make it possible to slice any other query you created, like getPostsByCategoryId.

  • src/sanity/queries/slice.ts
import { q, type Selection } from "groqd";
import { runQuery, type RunQueryOptions, type RunQueryParams } from "./runQuery";

const unknownArrayQuery = q("").filter();
export type UnknownArrayQuery = typeof unknownArrayQuery;

export const makeSlicedQuery = (
  query: UnknownArrayQuery,
  sliceStart: number,
  sliceEnd: number,
  orderings: string[] = ["_createdAt desc"],
) => query.order(...orderings).slice(sliceStart, sliceEnd);

export const runSlicedQuery = <S extends Selection>(
  query: UnknownArrayQuery,
  selection: S,
  sliceStart: number,
  sliceEnd: number,
  params?: RunQueryParams,
  options?: RunQueryOptions,
) =>
  runQuery(
    makeSlicedQuery(query, sliceStart, sliceEnd).grab$(selection),
    params,
    options,
  );

Here's an example for getting recent posts:

  • getRecentPosts.ts
import type { Selection } from "groqd";
import { runSlicedQuery, type UnknownArrayQuery } from "../slice";
import { makeGetPostsQuery } from "./getPosts";
import { makeGetPostsByCategoryIdQuery } from "./getPostsByCategoryId";
import type { RunQueryParams, RunQueryOptions } from "../runQuery";

const runRecentPostsQuery = <S extends Selection>(
  query: UnknownArrayQuery,
  selection: S,
  amount = 4,
  params?: RunQueryParams,
  options?: RunQueryOptions
) => runSlicedQuery(query, selection, 0, amount - 1, params, options);

export const getRecentPosts = <S extends Selection>(selection: S, amount = 4) =>
  runRecentPostsQuery(
    makeGetPostsQuery(),
    selection,
    amount,
    {},
    {
      next: { tags: ["post"] },
    }
  );

export const getRecentPostsByCategoryId = <S extends Selection>(
  categoryId: string,
  selection: S,
  amount = 3
) =>
  runRecentPostsQuery(
    makeGetPostsByCategoryIdQuery(),
    selection,
    amount,
    { categoryId },
    {
      next: { tags: [`post-categoryId-${categoryId}`] },
    }
  );
Buy Me a Coffee

Share this post