DEV Community

Cover image for The best way to fetch data from Sanity (using zod)
Lorenzo Rivosecchi
Lorenzo Rivosecchi

Posted on • Updated on

The best way to fetch data from Sanity (using zod)

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>[]'
Enter fullscreen mode Exit fullscreen mode

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;
} 
Enter fullscreen mode Exit fullscreen mode

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
}`
Enter fullscreen mode Exit fullscreen mode

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
}`
Enter fullscreen mode Exit fullscreen mode

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}
}`
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
  )
});
Enter fullscreen mode Exit fullscreen mode

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}
  }
}`
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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")
}`
Enter fullscreen mode Exit fullscreen mode

You could also get the fallback value by accessing another document in the dataset:

const pageProjection = `{
  "title": coalesce(title, *[_type == "siteSettings"][0].defaultTitle)
}`
Enter fullscreen mode Exit fullscreen mode

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\")"
});
Enter fullscreen mode Exit fullscreen mode

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()]
  });
Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
kmelve profile image
Knut Melvær

Thanks for sharing Lorenzo!

You might also want to check out groqd; it uses Zod under the hood. 🙇

Collapse
 
fibonacid profile image
Lorenzo Rivosecchi • Edited

Wow, I was thinking about building something similar when writing this.