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.
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.
About dependencies
Out of the 4 dependencies listed above, only Groqd and Zod are app dependencies. Sanity TypeGen and ts-to-zod are both dev dependencies we'll only be using in NPM scripts and won't actually be used at runtime.
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
Versions
The versions this command will install may differ from the ones that were installed at the time of writing. In case you want to replicate the exact environment, here are the ones that were installed:
- @sanity/cli: ^3.52.4
- @sanity/image-url: ^1.0.2
- @sanity/vision: ^3.52.4
- next-sanity: ^9.4.3
- sanity: ^3.52.4
- styled-components: ^6.1.12
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.
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.
{
"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'sgroq
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.
File structure
This configuration assumes that you have a sanity
folder under the src
folder. Please verify that these paths suit your file structure.
We are now ready to generate our types! Add the following NPM script to your 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 theschema
path we defined earlier. It also enforces required fields, i.e. wherever we usevalidation: (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 atproduction
.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!
{
// ...
"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.
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.
RTFD!
This post won't cover everything from groqd, only the necessary parts to go through the post. You can read the official documentation for it, it's really small and you can get through it fairly easily in under an hour!
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.
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:
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!).
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.
About .filter()
Just remember that filter()
(without arguments) translates to []
. I like to use it like that because I like to keep my strings with only the field names, but you could also just specify those brackets in q
, like q("body[]").select()
instead. We'll use filter
with arguments later on, when we build queries.
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!)
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]>,
);
};
About .grab$
You'll notice I always use grab$
and never grab
. That's because grab$
automatically converts null
fields to undefined
, which is almost always preferable, because null
appears whenever you query for a field that doesn't exist. If you're actually storing null
though, you should use grab
instead and use groqd's nullToUndefined
utility for the rest.
Select does not make the use of nullToUndefined
like grab$
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.
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
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 thefindCategoryBySlug
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.
get vs find
This naming convention is up to you. I use get
when I expect to get an error for falsy values, like null
, and find
when I expect to get null
for falsy values
Let's do the same for posts
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.
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.
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.
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.
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.
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.
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
.
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:
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}`] },
}
);