Handling references in portable text in GraphQL without Gatsby Source plugin

4 replies
Last updated: Mar 10, 2023
In GraphQL how do you handle references in portable text e.g handling a link to an internal document? The portable text field is returned as RAW JSON with a ref. In GROQ you can grab the referenced document in the
markDefs
but can’t seem to figure out how to do it in just plain GraphQL.
AI Update

When working with Portable Text in Sanity's GraphQL API, handling references in annotations (like internal links in markDefs) requires a specific schema configuration approach.

The Key Solution: Hoist Annotations as Schema Types

The most important thing to know is that if you plan to use Sanity's GraphQL API, you should hoist your annotation types (like internalLink) as separate schema types instead of defining them inline.

Here's the difference:

❌ Inline definition (works in GROQ, problematic in GraphQL):

{
  type: 'block',
  marks: {
    annotations: [
      {
        name: 'internalLink',
        type: 'object',  // inline object definition
        title: 'Internal link',
        fields: [
          {
            name: 'reference',
            type: 'reference',
            to: [{ type: 'post' }]
          }
        ]
      }
    ]
  }
}

✅ Hoisted type (works well with GraphQL):

First, create a separate schema type:

// schemas/internalLink.js
export default {
  name: 'internalLink',
  type: 'object',
  title: 'Internal link',
  fields: [
    {
      name: 'reference',
      type: 'reference',
      to: [{ type: 'post' }]
    }
  ]
}

Then reference it in your block configuration:

{
  type: 'block',
  marks: {
    annotations: [
      {
        type: 'internalLink'  // reference the hoisted type
      }
    ]
  }
}

Why This Matters

When you hoist the annotation as a schema type, Sanity's GraphQL API can properly generate the necessary types and resolvers to handle the reference. This allows you to query the referenced document directly in your GraphQL queries, similar to how you would use the -> operator in GROQ to dereference in markDefs.

With the hoisted type, your GraphQL query can resolve the reference properly, whereas inline object definitions don't get the same treatment in the GraphQL schema generation. The GraphQL API needs explicit schema types to create the proper resolvers for nested references.

This approach is specifically mentioned in the official Sanity documentation on configuring the Portable Text Editor, which states: "If you plan to use Sanity's GraphQL API, you should hoist internalLink as a schema type, and use type: 'internalLink' as the annotation, instead of the anonymous example above."

If you’re using the Gatsby Source plugin you can automatically resolve references on your raw fields . I’m not sure if this is true outside of the plugin, though.
I am not using Gatsby 🥲
If anyone searches this in the future, here’s my solution:

import { sanityApiServer } from "@/lib/sanity";

/**
 * Recursively iterates over an object looking for markDefs keys and resolving internal links to Sanity references.
 * @template T - The type of the object being resolved.
 * @param {T} object - The object to resolve.
 * @returns {Promise<T>} - A Promise that resolves to the modified object with internal links resolved.
 */
export async function resolveSanityTasks<T>(object: T): Promise<T> {
  // Recursively iterate over the object looking for keys called "markDefs"
  for (const KEY in object) {
    if (KEY === "markDefs") {
      // Extract the markDefs array
      const MARK_DEFS = object[KEY] as any[];
      if (Array.isArray(MARK_DEFS)) {
        /**
         * Loop over all the markDefs and search for internal links.
         * If they exist, query Sanity for the reference slugs.
         */
        object[KEY] = (await Promise.all(
          MARK_DEFS.map(async (item) => {
            // Check if the item is an internal link with a reference slug
            if (
              "_type" in item &&
              item._type === "internalLink" &&
              "reference" in item &&
              "_ref" in item.reference &&
              typeof item.reference._ref === "string"
            ) {
              try {
                // Query Sanity for the reference slug using the ID
                const RESPONSE = await sanityApiServer.fetch(`*[_id == "${item.reference._ref}"] { _type, slug }`);

                // Check that the query response is valid
                if (
                  Array.isArray(RESPONSE) &&
                  RESPONSE.length > 0 &&
                  typeof RESPONSE[0] === "object" &&
                  "_type" in RESPONSE[0] &&
                  typeof RESPONSE[0]._type === "string" &&
                  "slug" in RESPONSE[0] &&
                  typeof RESPONSE[0].slug === "object" &&
                  "current" in RESPONSE[0].slug &&
                  typeof RESPONSE[0].slug.current === "string"
                ) {
                  /**
                   * We need to capitalize the type to match
                   * the type returned from GraphQL
                   */
                  const TYPE = RESPONSE?.[0]._type;
                  const CAPITALIZED_TYPE = TYPE.charAt(0).toUpperCase() + TYPE.slice(1);

                  // Update the item with the reference slug and type
                  return {
                    ...item,
                    __typename: CAPITALIZED_TYPE,
                    slug: RESPONSE?.[0].slug,
                  };
                } else {
                  // If the query response is not valid, return the original item
                  return item;
                }
              } catch (errResponse) {
                // If there is an error with the query, throw an error
                throw new Error();
              }
            } else {
              // If the item is not an internal link with a reference slug, return the original item
              return item;
            }
          }),
        )) as T[Extract<keyof T, string>];
      } else {
        // If the markDefs array is not valid, recursively call this function on the object
        object[KEY] = (await resolveSanityTasks(object[KEY])) as T[Extract<keyof T, string>];
      }
    } else if (typeof object[KEY] === "object") {
      // If the value of this key is an object, recursively call this function on that object
      object[KEY] = (await resolveSanityTasks(object[KEY])) as T[Extract<keyof T, string>];
    }
  }
  // Return the modified object
  return object;
}
And I hate it.

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?