Posts by eivindml

All posts

The future of caching (and how to enable Sanity Live Content API with Next.js 15)

Recently, Next.js Conf 24 took place, and one of the major topics was caching—forever relevant. Next.js has a range of APIs across both the page and app routers for specifying cache and invalidation strategies. To clean up this landscape, they're introducing yet another new set of APIs for caching. 😅 (Jokes aside, this actually seems like a well-thought-out API that’s easier to reason about, based on the idea of progressive exposure to complexity.) However, I won’t go over the “Opening keynote” content here (maybe in a future post), but rather Sanity's presentation, “Live by Default” [1]. This, too, was largely about caching!

Caching is a necessary evil. Ideally, it should just happen automatically in the background without me, as a web developer, needing to deal with it. But we’re not there yet. Next.js attempted to improve this by making caching the default for all fetch calls—you have to explicitly specify if you wanted dynamic data. The idea was likely that for simpler tasks, we wouldn’t have to worry about caching at all. Nice idea, but many probably found this “magic” hard to reason around. Why am I not getting the latest data when I reload my page? Suddenly, you had to understand how caching works in Next.js under the hood, even for simple use cases.

What made Sanity's presentation particularly interesting for me was that perhaps we’re now at the point where we don’t have to deal with caching and invalidation! They introduced what they’re calling the Live Content API (LCAPI). Simen Svale presented it as a solution that’s “API-fresh, CDN-fast, and CDN-cheap.” We can now make changes to our content in Sanity, hit publish, and see the changes reflected on the site in real time—even though we’re using Sanity’s CDN API and have statically cached pages in Next.js! The real-time updating is cool, but what really excites me is the precision and simplicity with which the cache is now invalidated!

Her kan vi se sanntidsoppdatering fungere i Sanity og i nettleseren.

What follows is my attempt to understand what's happening behind the scenes. It might be inaccurate, and I’m open to corrections! It starts with each piece of content being tagged with a key, so that the source of content can be tracked. This is similar, or the same, as for the "stega" used with Presentation tool. These keys are then used as cache keys in their CDN. I’m not sure exactly how granular this tracking is, but for "stega", it seems to track at least on a per-field basis, except for PortableText, where it can track by block or mark. This allows them to precisely invalidate the cache and update only the changed data by sending over the diff.

Furthermore, when making a query, you’ll notice a new syncTags array in the response. This seems to indicate which document types are affected. For instance, if I’m querying “page,” “person,” and “event,” I get back three tags. If I have a blank query filter, I get a unique tag that seems to cover the entire dataset.

Screenshot from RapidAPI showing syncTags.

If you're curious, you can connect to the live API using curl. Then you can make some changes and see what gets communicated.

curl -N -H "Accept: text/event-stream" "https://<project-id>.api.sanity.io/vX/data/live/events/production"

We see that some of the same tags from the syncTags are sent. Two messages are always sent for each published change. I'm not exactly sure what these represent, but they can at least be used to mark and invalidate the cache.

Screenrecording from terminal showing a connection to the LCAPI. Using curl.

With this information, we could theoretically tag all our fetch() calls to the Sanity API with cache tags, establish a permanent WebSocket connection to the Live Content API, receive tags, and invalidate the correct cache with a server action and revalidateTag(). But fortunately, Sanity has done this work for us and wrapped it in a function and a component available in next-sanity.

How to get live data with Sanity and Next.js 15

Set up your Next.js app as usual. This also works fine with older versions of Next.js, like v14. Just note that earlier versions have caching enabled by default, whereas in v15, it’s now opt-in. Next, update to the latest version of next-sanity with pnpm add next-sanity@latest.

Then configure createClient, and finally re-export the sanityFetch function and the SanityLive component. [2]

import {createClient, defineLive} from 'next-sanity'

const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
  useCdn: true,
  apiVersion: 'vX', // Target the experimental API version
})

export const {sanityFetch, SanityLive} = defineLive({
  client,
})

That’s really all the setup needed to start using it, but it’s a good idea to add an error boundary component. When I first tested out Live Content, I got an error that caused Safari to reload the page in an endless loop, making it impossible to read the error message. With LiveErrorBoundary, errors were handled correctly. The code is from the next-sanityGitHub repo [3].

"use client";

import { useEffect } from "react";
import {
  ErrorBoundary as ReactErrorBoundary,
  type FallbackProps,
} from "react-error-boundary";
import { toast } from "sonner";

export function LiveErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    <ReactErrorBoundary FallbackComponent={Fallback}>
      {children}
    </ReactErrorBoundary>
  );
}

function Fallback({ error }: FallbackProps) {
  useEffect(() => {
    const msg = "Couldn't connect to Live Content API";
    console.error(`${msg}: `, error);
    const toastId = toast.error(msg, {
      id: "live-error-boundary",
      duration: Infinity,
      description: "See the browser console for more information",
      action: {
        label: "Retry",
        onClick: () => location.reload(),
      },
    });
    return () => {
      toast.dismiss(toastId);
    };
  }, [error]);

  return null;
}

Add SanityLive to layout.tsx, optionally with an error boundary.

<LiveErrorBoundary>
  <SanityLive />
</LiveErrorBoundary>

Then use sanityFetch with your query.

const {data: page} = await sanityFetch({ query })

Before this works, you also need to add the correct CORS origin to your Sanity project. When working locally, http://localhost:3000 is sufficient.

Voilà! Data is now streamed to the client and automatically rehydrated on change. Even if we configure everything to be cached and routes are statically generated (export const fetchCache = "force-cache"; export const dynamic = "force-static";), Sanity will give us fresh content.

Her kan vi se sanntidsoppdatering fungere i Sanity og i nettleseren.

A few things to note

I had to work through a few errors to get this working, so here are some things to note:

  1. When I initially had an error, it caused Safari to enter an endless loop trying to fetch content and then displaying an error message. The refresh happened so quickly that I couldn’t even see the error message. Wrapping <SanityLive /> in an error boundary component finally allowed me to see the error properly. (https://github.com/sanity-io/next.js/blob/canary/examples/cms-sanity/app/(blog)/live-error-boundary.tsx)
  2. The old client.fetch() function returned data directly, while the new sanityFetch() returns data as { data }. I had a helper function (makeRequest() that handles validation, etc.) that prevented me from noticing this immediately. Make sure to account for this!
  3. I initially forgot to set an explicit apiVersion in createClient. This gave me an error saying, “Perspectives are not supported for this version.” You can set it to "vX" (experimental) or use a date, like "2024-10-25", which also worked.
  4. None of the Live Content documentation mentioned that you need to set the CORS origin. Do this via https://sanity.io/manage.

Further Thoughts

What strikes me after watching these two presentations back-to-back is how Next.js is now stepping back from default caching and “magic” towards progressive disclosure of complexity. Now, what you expect (dynamic, fresh data on each page reload) is the default. You can then gradually add caching where it’s needed, since this is a simpler mental model. Meanwhile, Sanity is going in the opposite direction, adding a lot of “magic.” I think for this to work, the magic has to be so effective that it crosses a threshold; otherwise, it can be frustrating, and we may still need to understand the underlying model. Which often adds even more complexity.

But I’m hopeful this is the way forward. I’ve already implemented support for this setup in the project I’m currently working on for a client (including Next.js 15, Tailwind 4, and Biome 😅).

[1] https://www.youtube.com/live/WLHHzsqGSVQ?t=19490s
[2] https://github.com/sanity-io/next-sanity?tab=readme-ov-file#live-content-api
[3] https://github.com/sanity-io/next.js/tree/canary/examples/cms-sanity
[4] https://www.sanity.io/live
[5] https://www.sanity.io/docs/live-api-reference