The best way to fetch data from Sanity (using zod)
Tags: typescript, javascript, sanity, groq
Published: 2023-02-23 22:35:24

I have been building websites with TypeScript and [Sanity](https://www.sanity.io/) 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](https://zod.dev/) we can reduce the type to the shape we want by defining a schema and use it at runtime to validate the JSON response.

```ts
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.


```ts
// 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`.

```ts
// 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.

```ts
/** 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](https://sanity-io.github.io/GROQ/draft/#sec-Projection-traversal). Since we have decoupled it from the `getPageBySlug` function, we can now reuse it in other queries:

```ts
// 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`.

```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:

```tsx
/** 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.

```tsx
/** 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:

```tsx
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.

```ts
const pageProjection = `{
  "title": coalesce(title, "Missing title")
}`
``` 

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

```ts
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`:

```ts
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](https://github.com/FormidableLabs/groqd) from FormidableLabs. It exposes a `q` function that can be used to build runtime type safe GROQ queries in a more concise format:

```ts
import { q } from "groqd"

const { query, schema } = q("*")
  .filter("_type == 'page'")
  .grab({
    title: q.string(),
    slug: ["slug.current", q.string()],
    publishedAt: ["_createdAt", q.string()]
  });
```