Back to All Posts

March 6, 2024

Fast Refresh for Your Content

See changes in real time with the new <Pump /> React Server Component.


One of the top painpoints we hear about Headless CMSs revolves around the previewing experience. The fact that the content and the code are decoupled is great for building beautiful, performant websites with modern stacks, but the tradeoff is often times around the edit → preview → publish experience. Editors now struggle to edit content and see how it looks in their websites instantly, in real time.

Now, while many Headless CMSs provide “Live Previews”, they all require a non-negligible amount of development work… and we’re back at the tension between developers and content editors: what benefits content editors comes at a cost to developers—in terms of development time. We want to minimize this tension, and we’re doing so by removing friction, every step of the way.

This is our take. “Fast refresh for content”. We’re calling the component that powers this <Pump />.

You can get started with Pump by installing the latest basehub SDK:

pnpm i basehub@latest

And then using it:

// app/page.tsx

import { Pump } from "basehub/react-pump"
import { draftMode } from "next/headers"

const Page = () => {
  return (
    <Pump queries={[{ _sys: { id: true } }]} draft={draftMode().isEnabled}>
      {async ([data]) => {
        "use server"

        // some notes
        // 1. `data` is typesafe.
        // 2. if draft === true, this will run every time you edit the content, in real time.
        // 3. you can render nested Server Components (Client or Server) here, go nuts.

        return (
          <pre>
            <code>{JSON.stringify(data, null, 2)}</code>
          </pre>
        )
      }}
    </Pump>
  )
}

export default Page

Pump is a React Server Component that gets generated with the basehub SDK. It leverages RSC, Server Actions, and the existing basehub client to subscribe to changes in real time with minimal development effort.

Go to our Docs to see how you can integrate Pump right away, or read on for a short technical dive.


Design Principles

  1. It should be easy for developers to integrate. We want to build stuff that delights both developers and content editors. By making our SDK easy to integrate, developers can make content editable without it disrupting their workflows.

  2. It should be compatible with the existing basehub client. We know that, in some cases, developers would still lean into the existing basehub SDK to reach for more flexibility or reusability. Pump shouldn’t be a breaking change.

  3. It shouldn’t force developers out of using React Server Components. Most “Live Preview” strategies force developers out of RSC, as they rely on client-side hooks. This not only results in a worse DX. It also means developers are forced to ship more JS to the client, negatively impacting performance.

Initial POC

Our first idea was to build a React Server Component that, depending on a prop, it would query our Production API (and that’s it), or it would render a Client Component that would subscribe to changes in real time from our Draft API.

The idea was simple, and had potential. We imagined a DX like this:

import { OurMagicalComponent } from "basehub/path/to/magical-component"
import { draftMode } from "next/headers"

const Page = () => {
  return (
    <OurMagicalComponent
      query={{ homepage: { title: true } }}
      draft={draftMode().isEnabled}
    >
      {(data) => {
        return <h1>{data.homepage.title}</h1>
      }}
    </OurMagicalComponent>
  )
}
  • The query parameter would be the same type safe parameter basehub receives.

  • draft would tell the component to hit the Draft API and subscribe to changes in real time.

  • The render function would receive a data parameter with the result of the query (obviously typesafe).

Importantly, the JSX returned from the render function would still be a server component. That means, if you’re rendering a blog post with code snippets, you can keep the syntax highlighter code in the server (which is typically heavy). This came at a stark contrast to other CMSs Live Preview integrations, which relied on client side hooks, and therefore required devs to switch to client-run code prematurely.

But how would this work on the inside? Immediately afterwards, we started working on a POC inside our webapp. On the first commit, Pump looked like this:

import {
  Children,
  ClientQueryProvider,
  LoginAndReplicacheStuff,
} from "./client"

export const Pump = async ({
  children,
  draft,
  query,
}: {
  children: Children
  draft: boolean
  query: string // genql query | raw string
}) => {
  if (draft) {
    return (
      <LoginAndReplicacheStuff query={query}>
        {children}
      </LoginAndReplicacheStuff>
    )
  } else {
    const data = await fetch("https://api.basehub.com/graphql", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ query }),
    })
    return <ClientQueryProvider data={data}>{children}</ClientQueryProvider>
  }
}

Where LoginAndReplicacheStuff would subscribe to updates via Pusher and Replicache—the same strategy we use in our dashbaord—to get all of the working blocks and store them locally in IndexedDB. With all of the blocks available, we could then resolve the queries in real time. But how on earth would we resolve a GraphQL query in the browser?

We tried running graphql-yoga on the client, and while it did work, it came with a bunch of tradeoffs:

  • The bundle would be subtantially larger, as we’d need to ship graphql and graphql-yoga.

  • We’d need a big refactor in order to abstract parts of our existing GraphQL Server logic to recreate the GraphQL Schema on the fly.

After trying to make it work for a couple of days, we found a much simpler approach—obvious in retrospect.

Simple > Complex

Instead of loading Replicache and resolving GraphQL queries in the client, we’d just use Pusher to listen for when a change was made in the dashboard, and then re-send the queries to our Draft API. Our Draft API was slow back then, but if we made it fast enough, we’d get the experience we wanted without all the complexity we were about to introduce into our codebase. This worked!

There was still an unaddressed blocker. An error message that read ‘Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with “use server”’.

Of course. We were trying to send a render function that would be then sent down to a Client Component, which obviously can’t be done. This should be sent down as a Server Action, as we’d want that code to be executed on the server. And that’s why we require developers to send an async function with “use server” into Pump.

After all of these blockers were taken care of, and we were “over the hill”, we added query deduping, added support for parallel queries, and cleaned the code a bit. Take a look at the code, which is open source.

The Result

This is how you can use Pump in Next.js. Take it for a spin and let us know what you think

// app/page.tsx

import { Pump } from "basehub/react-pump"
import { draftMode } from "next/headers"

const Page = () => {
  return (
    <Pump queries={[{ _sys: { id: true } }]} draft={draftMode().isEnabled}>
      {async ([data]) => {
        "use server"

        // some notes
        // 1. `data` is typesafe.
        // 2. if draft === true, this will run every time you edit the content, in real time.
        // 3. you can render nested Server Components (Client or Server) here, go nuts.

        return (
          <pre>
            <code>{JSON.stringify(data, null, 2)}</code>
          </pre>
        )
      }}
    </Pump>
  )
}

export default Page

FAQ

  • “Does it work with other JS frameworks?” Not at this moment. We only support Next.js, as it’s the only React framework that supports RSC (as far as we know).

  • “Why do I need to pass a Server Action?“ Because when draft === true, this function will be re-executed when content from BaseHub changes, and in order to pass a function from the server to the client, it needs to be a Server Action.

  • “Does it have any performance impact in production?” When draft === false, there’s no performance impact. The server code for react-pump is just 2KB.

  • “Why the name ‘Pump’?” We were seeing the TV Series “Super Pumped” (about the Uber story) and the name just felt right.