GraphQL-like API Routes with GROQimage
All Stories

GraphQL-like API Routes with GROQ

One of my favorite things about building apps with Remix (or Next) is that I no longer have to think about data over-fetching problems on the client.

For example, say I have a page that displays a list of books where I only need the title, author's name, and cover image of each book. I can send those specific values and filter out the rest of the attributes on the server like this:

export async function loader() {
  const books = await getBooks();
  // ☝️ big JSON object
  return json(
    books.map((book) => ({
	  // ☝️ filtered
      id: book.id,
      title: book.title,
      authorName: `${book.author.firstName} ${book.author.lastName}`,
      // ☝️ merged content
      coverURL: book.coverImage.url,
      // ☝️ flattened attribute
    }))
  );
}

export default function () {
  const data = useLoaderData<typeof loader>();
  // ☝️ only the data needed
  return (
    <>
      <h1>Books</h1>
      {data.map((book) => (
        <div key={book.id}>
          <img
            src={book.coverURL}
            alt={`${book.title} Cover`}
            width="250px"
          />
          <h2>{book.title}</h2>
          <p>{book.authorName}</p>
        </div>
      ))}
    </>
  );
}

Just like that, the client is only receiving exactly the data it needs to render - no over-fetching and no need to write, deploy, and manage a full GraphQL backend service. However, this solution only works for the web version of our product.

If we're also building a mobile app, we'll still need to build some sort of API to serve data to those external clients - which puts us right back in the tradeoff trench between over-fetching with a REST API or dealing with the complexity of a GraphQL layer.

Well, I actually think there's a third option that gives us the best of both worlds. So in this post, I'm going to show you how you can easily build RESTful API routes that prevent over-fetching to mobile apps or other external clients by using GROQ.

Let's get started by answering probably your first question...

🤷🏻‍♂️ What the heck is GROQ?

GROQ (Graph-Relational Object Queries) is a querying language for JSON documents. It was developed by Sanity.io - one of the leading headless CMS platforms - in order to give developers the ability to declaratively filter and merge content without needing to use GraphQL.

For example, let's look at a JSON document that represents our array of books from the earlier example:

// books.json
[
  {
    id: 1,
    title: "Braiding Sweetgrass",
    author: {
      id: 1,
      firstName: "Robin Wall",
      lastName: "Kimmerer",
    },
    coverImage: {
      url: "https://upload.wikimedia.org/wikipedia/en/a/a4/Braiding_Sweetgrass.jpg",
      artist: "Gretchen Achilles",
    },
    publisher: {
      id: 1,
      name: "Milkweed Editions",
    },
    publishingYear: "2013",
    pages: 408,
    isbns: ["978-1-57131-335-5"],
  },
  ... // more books
]

If we want to filter this so that it only contains the title, author.firstName, author.lastName, and coverImage.url properties that we used in our loader, we can write the following GROQ query:

// GROQ query
*[] {
  id
  title,
  author {
    firstName,
    lastName
  },
  coverImage {
    url
  }
}

Running that against the above document gives us the following JSON with only our declared attributes present and the others filtered out:

[
  {
    id: 1,
    title: "Braiding Sweetgrass",
    author: {
      firstName: "Robin Wall",
      lastName: "Kimmerer",
    },
    coverImage: {
      url: "https://upload.wikimedia.org/wikipedia/en/a/a4/Braiding_Sweetgrass.jpg",
    },
  },
  ... // more books
]

Pretty cool, right? Well, it gets cooler. Let me show you a little more of what GROQ can do...

🔮 Projections and Selectors

You know how in our loader function we remapped author.firstName + author.lastName to authorName and coverImage.url to coverURL? Well, we can do that in GROQ using projections.

Projections are ways of defining custom attributes in our target JSON schema. We can use projections to remap keys, flatten attributes, and even calculate new values on the fly. For example, the following query will do the same remapping that we did in our loader function:

*[] {
  id
  title,
  "authorName": author.firstName + author.lastName,
  "coverURL": coverImage.url
}
[
  {
    id: 1,
    title: "Braiding Sweetgrass",
    authorName: "Robin Wall Kimmerer",
    coverURL: "https://upload.wikimedia.org/wikipedia/en/a/a4/Braiding_Sweetgrass.jpg",
  },
  ... // more books
]

How amazing is that?!

Well, hold on, cause it gets better. Let's say we also only want books from a specific author. Well, we can add a selector to the beginning of our GROQ query to make that happen:

*[author.id == 2] {
  id,
  title,
  "authorName": author.firstName + author.lastName,
  "coverURL": coverImage.url
}

Now, when we rerun our query against our document, we get this:

[
  {
    id: 2,
    title: "The Omnivore's Dilemma",
    authorName:"Michael Pollan",
    coverURL: "https://upload.wikimedia.org/wikipedia/en/0/01/OmnivoresDilemma_full.jpg",
  },
  ... // more books by daddy Pollan
]

With filters, projections, and selectors, GROQ is a powerful way of performing complex operations on JSON documents that puts the querier in control of the data.

Which leads me to the point of this article...

GROQin' Our API Routes

By using GROQ with API routes in Remix (or Next), we can essentially add GraphQL-like behavior to RESTful endpoints. Implementing GROQ in an API route is also super straightforward. Thanks to Sanity's commitment to open source, all we really have to do is install their groq-js package:

npm i groq-js

This library contains two key functions: parse and evaluate. We use parse to turn our query into a schema tree, and we use evaluate to align our dataset with that newly defined schema. Here's what that looks like in a Remix loader function:

export async function loader({ request }: LoaderArgs) {
  const books = await getBooks();

  const query = new URL(request.url).searchParams.get("query");
  const tree = parse(query || "*[]");
  const value = await evaluate(tree, { dataset: books });

  return json(await value.get());
}

That's literally it. We're done.

Now, while we're building the REST API for our mobile app, we don't have to worry about over-fetching or misaligned data schemas. Our app can just send a GROQ query to our Remix app using a search parameter and keep on keepin' on.

If you want to see the full source code and play with this example, checkout the repo for this post on GitHub.

✨ What do you think?

GROQ is awesome, and I don't know why more people don't talk about it! I'd love to hear what you think of GROQ on Twitter or LinkedIn. You can also email me if social media isn't your thing.

Also, if you want to get notified the next time I post an article like this, you can sign up for my newsletter using the form down below.

Until next time.

Best,
Drew Lyton
Drew Lyton
Friday, March 31, 2023

I'm a software engineer, ex-founder and writer who cares deeply about creating a better relationship between technology and society. I'm currently writing a book about early stage entrepreneurship.

LET'S KEEP THE CONVERSATION GOING

If this article has been valuable and you'd like to be notified the next time I post, subscribe to my newsletter!

Each week, I share my latest post along with three other works from people smarter than me on whatever topic is top of mind. Of course, you can unsubscribe at anytime.