I have been building websites with TypeScript and Sanity for a few years and have learned a few tricks along the way.
This is my recommendation on how to structure your code to maximise code reuse and type safety.
Intro to Zod + Sanity
When fetching data from Sanity, the result of every query is of type any
. With Zod we can reduce the type to the shape we want by defining a schema and use it at runtime to validate the JSON response.
import { client } from "./sanity";
import { z } from "zod";
// basic shape of a sanity document
const docSchema = z.record(z.string(), z.any());
// query that returns all documents
const result = await client.fetch(`*`);
// validation of the query result
const docs = docSchema.array().parse(result);
docs.foo.bar();
// Property 'foo' does not exist on type 'Record<string, any>[]'
Defining Document Schemas
In the code example below we have module that defines a page model and a getter function. The model is first defined by creating a schema with z.object
, then we extract the complimentary type using the z.infer
utility.
Note that while
pageSchema
is a runtime object,PageSchema
is just a type.
// page.ts
import { client } from "./sanity";
/** Page Validator */
export const pageSchema: z.object({
title: z.string(),
slug: z.string(),
publishedAt: z.string()
});
/** Page Interface */
export type PageSchema = z.infer<typeof pageSchema>;
/** Page Getter */
export async function getPageBySlug(slug: string) {
// GROQ query to get a page and transform it in the desired shape
const query = `*[_type == "page" && slug.current == $slug][0]{
title,
"slug": slug.current,
"publishedAt": _createdAt
}`;
// Fetcher call that returns any
const params = { slug };
const data = await client.fetch(query, params);
// Will throw when data does not match the schema.
const page = pageSchema.parse(data);
return page;
}
Reusing GROQ projections
If you look closely at the code you might notice that the body of the query has the same shape of pageSchema
.
// schema
z.object({
title: z.string(),
slug: z.string(),
publishedAt: z.string()
});
// groq
`{
title,
"slug": slug.current,
"publishedAt": _createdAt
}`
We can build upon this similarity by grabbing the body of the query and defining it at the top level, close to our schema definition.
/** Page Validator */
export const pageSchema: z.object({
title: z.string(),
slug: z.string(),
publishedAt: z.string()
});
/** Page Projection (GROQ) */
export const pageProjection = `{
title,
"slug": slug.current,
"publishedAt": _createdAt
}`
The word projection comes directly from the GROQ spec. Since we have decoupled it from the getPageBySlug
function, we can now reuse it in other queries:
// The query from before
`*[_type == "page" && slug.current == $slug][0]` + pageProjection;
// A query to get all pages
`*[_type == "page"]` + pageProjection;
// A totally different query
`*[_type == "navigation"][0]{
pages[]${pageProjection}
}`
Combining Schemas
Since we have decoupled the page projection from the getPageBySlug
, we can now import the code from page.ts
in a new module called navigation.ts
.
// navigation.ts
import { client } from "./sanity";
import { pageSchema, pageProjection } from "./page";
/** Navigation Validator */
export const navigationSchema: z.object({
pages: pageSchema.array()
});
/** Navigation Projection (GROQ) */
export const navigationProjection = `{
pages[]->${pageProjection}
}`
/** Navigation Interface */
export type NavigationSchema = z.infer<typeof navigationSchema>;
/** Navigation Getter */
export async function getNavigation() {
const query = `*[_type == "navigation"][0]` + navigationProjection;
// Fetcher call that returns any
const params = { slug };
const data = await client.fetch(query, params);
// Will throw when data does not match the schema.
const navigation = navigationSchema.parse(data);
return navigation;
}
Extending Schemas
As shown in the previous example, reusing projections is pretty convenient when resolving references from one document type to another. In some cases you might want to extend an existing schema in order to retrieve certain fields.
Let's say that we are building a navigation component that renders a list of pages using React. To render an array of things in React we need to provide a unique key to each item, but we don't have anything like that in the query.
The solution is to extend the pages
field into our navigation
to include the field.
We can extend a schema in multiple ways, but I tend to prefer this one because it's easy to read:
/** Navigation Validator */
export const navigationSchema: z.object({
pages: z.array(
_key: z.string(),
...pageSchema.shape
)
});
To extend a projection we can use the GROQ ...
syntax to merge the page projection fields within another projection.
/** Navigation Projection (GROQ) */
export const navigationProjection = `{
pages[]{
_key,
...@->${pageProjection}
}
}`
Benefits of this architecture
I think that this architecture has a lot of advantages:
- Splitting queries into chunks make the code more readable and maintainable.
- Having a consistent content model makes authoring components easier.
- Moving to another CMS should be easy. Reimplement the getters and leave the view as it is.
Missing Fields
As a closing note, I would mention that it's probably a good idea to have a consistent strategy for handling missing fields.
If the page title is missing, pageSchema
will throw an error. I find that it's better to deal with missing data within the view layer because you might want to provide different fallbacks depending on the situation.
Here is an example:
import type { PageSchema } from "./page"
function PageComponent({ title }: PageSchema) {
return (
<main>
<h1>{title || "Missing title"}</h1>
</main>
)
}
If dealing with nullable fields in the view layer seems annoying, you can provide a default value at the GROQ level using the coalesce
directive.
const pageProjection = `{
"title": coalesce(title, "Missing title")
}`
You could also get the fallback value by accessing another document in the dataset:
const pageProjection = `{
"title": coalesce(title, *[_type == "siteSettings"][0].defaultTitle)
}`
But if you want to keep your Sanity bill lower you may prefer to handle the default using catch
:
const pageSchema = z.object({
title: "z.string().catch(\"Missing title\")"
});
FormidableLabs/groqd
If you prefer using a library to deal with this stuff, check out groqd from FormidableLabs. It exposes a q
function that can be used to build runtime type safe GROQ queries in a more concise format:
import { q } from "groqd"
const { query, schema } = q("*")
.filter("_type == 'page'")
.grab({
title: q.string(),
slug: ["slug.current", q.string()],
publishedAt: ["_createdAt", q.string()]
});
Latest comments (2)
Thanks for sharing Lorenzo!
You might also want to check out groqd; it uses Zod under the hood. 🙇
Wow, I was thinking about building something similar when writing this.