Headless Content Management Systems are great because they decouple the frontend from the backend logic. However, sometimes this decoupling can also be a hinderance.

When someone makes changes to the content via the CMS, they usually don't get it done in one go and hit publish - it's an iterative process, going back and forth between CMS and website. Editors might need to check whether a piece of text fits the layout, or they may have to tweak an image so the crop looks good on all devices. To do this, they'll typically need some sort of visual preview that shows the new content in the actual context of the website.

For static websites, that's easier said than done.

Content changes on static websites require a rebuild, and that process can take a while. When you're editing content in a headless CMS like Sanity, you don't have access to a local dev server - you need to preview changes on the web somehow. Even for small sites and even with blazingly fast SSGs like Eleventy, building and deploying a new version can take a while.

When you're in the middle of writing, having to wait for every tiny change to become visible can feel excruciatingly slow. So we need a way to render updates instantly on demand, without actually having to rebuild the entire site each time.


Here's what we're trying to achieve:

Serverless Functions to the Rescue?

This is quite a common problem, so there are existing solutions. They revolve around making some parts of your site available for on-demand building by using serverless functions.

Eleventy has the ability to run inside a serverless function as well, and it provides the Serverless Bundler Plugin to do that. Basically, the plugin bundles your entire site's source code (plus some metadata) into a serverless function that you can call to trigger a new partial build.


👉 FYI: The upcoming v3 release of Eleventy (currently in beta) will not include the Serverless Plugin as part of the core package anymore, precisely because the current implementation is quite heavily geared towards Netlify and their specific serverless architecture. To keep the project as vendor-agnostic as possible, the functionality will probably be handled by external third-party-plugins in the future.

The most common scenario here is to have such a function run on the same infrastructure that hosts the regular static site. Providers like Netlify, Vercel, AWS or Cloudflare all have slightly different expectations when it comes to serverless functions though, so the exact implementation varies. All dependencies of your build process need to be packaged and bundled along with the function, and some platforms (in our case Cloudflare) don't even run them in a node environment at all, which is its own set of trouble.

One of the coolest things about Eleventy is its independence from frameworks and vendors. You can host a static Eleventy site anywhere from a simple shared webserver to a full-on bells-and-whistles cloud provider, and switching between them is remarkably easy.

So for the Sanity × Eleventy setup we're building at Codista, we really wanted to avoid getting locked-in to a specific provider and their serverless architecture. We also wanted to have more control over the infrastructure and the associated costs.

So we did what every engineer in that position would do: We rolled our own solution. 😅

The DIY-Solution

The basic idea for our preview service was to have our own small server somewhere. Everytime someone deploys a new version of our 11ty project, our CI process would automatically push the latest source code to that preview server too and run a build, to pre-generate all the static assets like CSS and Javascript..

A node script running on there will then accept GET requests to re-build parts of our site when the underlying Sanity content changes and spit out the updated HTML. We could then show that updated HTML right in the CMS as a preview.

To get this off the ground, we essentially need three things:

  1. A way to render specific parts of the site on-demand
  2. A way to fetch unpublished data changes from the CMS
  3. A way to display the rendered preview HTML to content editors in Sanity

1. On-Demand Building

The first piece of the puzzle is a way to trigger a new build when the request comes in. Usually, builds would be triggered from the command line or from a CI server, using the predefined `npx eleventy` command or similar. But it's also possible to run Eleventy through its programmatic API instead. You'll need to supply an input (a file or a directoy of files to parse), an output (somewhere for Eleventy to write the finished files) and a configuration object.

Here's an example of such a function:

// preview/server.js
import Eleventy from '@11ty/eleventy'

async function buildPreview(request) {
    // get some data from the incoming GET request
    const { path: url, query } = request
    let preview = null

    // look up the url from the request (i.e. "/about")
    // and try to match it to a input template src (i.e. "aboutPage.njk")
    // using the JSON file we saved earlier
    const inputPath = mapURLtoInputPath(url)

    // Run Eleventy programmatically
    const eleventy = new Eleventy(inputPath, null, {
        singleTemplateScope: true,
        inputDir: INPUT_DIR,
        config: function (eleventyConfig) {
            // make the request data available in Eleventy
            eleventyConfig.addGlobalData('preview', { url, query })
        }
    })
    // write output directly to memory as JSON instead of the file system
    const outputJSON = await eleventy.toJSON()

    // output will be a list of rendered pages,
    // depending on the configuration of our input source
    if (Array.isArray(outputJSON)) {
        preview = outputJSON.find((page) => page.url === url)
    }

    return preview
}

Let's say we want to call GET preview.codista.com/myproject/about from within the CMS to get a preview of the "about us" page. First, we will need a way to translate the permalink part of that request (`/about`) to an input file in the source code like src/pages/about.njk that Eleventy can render.

Luckily, Eleventy already does this in reverse when it builds the site - so we can hook into its contentMap event to get a neat map of all the URLs in our site to their respective input paths. Writing this map to a JSON file will make it available later on at runtime, when our preview function is called.

// eleventy.config.js
eleventyConfig.on('eleventy.contentMap', (map) => {
    const fileName = path.join(options.outputDir, '/preview/urls.json')
    fs.writeFileSync(fileName, JSON.stringify(map.urlToInputPath, null, 2))
})

The generated output looks somehing like this:

{
    "/sitemap.xml": {
        "inputPath": "./src/site/sitemap.xml.njk",
        "groupNumber": 0
    },
    "/": {
        "inputPath": "./src/site/cms/homePage.njk",
        "groupNumber": 0
    },
    "/about/": {
        "inputPath": "./src/site/cms/aboutPage.njk",
        "groupNumber": 0
    },
    ...
}

Listen for preview requests

We'll use a small express server to have our script listen for preview requests. Here's a (simplified) version of how that looks:

// preview/server.js
import express from 'express'
const app = express()

app.get('*', async (req, res, next) => {
    const { path: url } = req

    // check early if the requested URL matches any input sources.
    // if not, bail
    if (mapURLtoInputPath(url)) {
        res.status(404).send(`can't resolve URL to input file: ${url}`)
    }

    try {
        // call our preview function
        const output = await buildPreview(req)

        // check if we have HTML to output
        if (output) {
            res.send(output.content)
        } else {
            throw new Error(`can't build preview for URL: ${url}`)
        }
    } catch (err) {
        // pass any build errors to the express default error handler
        return next(err)
    }
})

ℹ️ The production version would also check for a security token to authenticate requests, as well as a revision id used to cache previews, so we don't run multiple builds when nothing has changed.

Putting all that together, we end up with a script that we can run on our preview server. You can find the final version here. We'll give it a special environment flag so we can fine-tune the build logic for this scenario later.

$ NODE_ENV=preview node preview/server.js

Right, that's the on-demand-building taken care of. Let's move to the next step!

2. Getting Draft Data from Sanity

In our regular build setup, we want to fetch CMS data from the Sanity API whenever a new build runs. Sanity provides a helpful client package that takes care of the internal heavy lifting. It's a good idea to build a little utility function to configure that client first:

// utils/sanity.js
import { createClient } from '@sanity/client'

export const getClient = function () {
    // basic client config
    let config = {
        // your project id in sanity
        projectId: process.env.SANITY_STUDIO_PROJECT_ID,
        // datasets are basically databases. default is "production"
        dataset: process.env.SANITY_STUDIO_DATASET,
        // api version takes any date and figures out the correct version from there
        apiVersion: '2024-08-01',
        // perspectives define what kind of data you want, more on that in a second
        perspective: 'published',
        // use sanity's CDN for content at the edge
        useCdn: true
    }
    return createClient(config)
}

Through the Eleventy data cascade, we can make a new global data file for each content type, for example data/cms/aboutPage.js. Exporting a function from that file will then cause Eleventy to fetch the data for us and expose it through a cms.aboutPage variable later. We just need to pass it a query (Sanity uses GROQ as its query language) to describe which content we want to have returned.

// src/data/cms/aboutPage.js
import { getClient } from '../utils/sanity.js'

const query = `*[_type == "aboutPage"]{...}`

export default async function getAboutPage() {
    const client = getClient()
    return await client.fetch(query)
}

Perspectives in Sanity

When an editor makes changes to the content, these changes are not published straight away but rather saved as a "draft" state in the document. Querying the Sanity API with the regular settings will not return these changes, as the default is to return only "published" data.

If we want to access draft data, we need to pass an adjusted configuration object to the Sanity client that asks for a different "perspective" (Sanity lingo for different views into your data) of previewDrafts. Since that data is private, we'll also need to provide a secret auth token that can be obtained through the Sanity admin. Finally, we can't use the built-in CDN for draft data, so we'll set useCdn: false.

// utils/sanity.js
import { createClient } from '@sanity/client'

export const getClient = function () {
    // basic client config
    let config = {
        projectId: process.env.SANITY_STUDIO_PROJECT_ID,
        dataset: process.env.SANITY_STUDIO_DATASET,
        apiVersion: '2024-08-01',
        perspective: 'published',
        useCdn: true
    }

    // adjust the settings when we're running in preview mode
    if (process.env.NODE_ENV === 'preview') {
        config = Object.assign(config, {
            // tell sanity to return unpublished drafts as well
            // note that we need an auth token to access that data
            token: process.env.SANITY_AUTH_TOKEN,
            perspective: 'previewDrafts',
            // we can't use the CDN when fetching unpublished data
            useCdn: false
        })
    }

    return createClient(config)
}

By making these changes directly in the API client, we don't need to change anything about our data fetching logic. All builds running in the "preview" node environment will automatically have access to the latest draft changes.

3. Displaying the Preview

We're almost there! We already have a way to request preview HTML for a specific URL and render it with the most up-to-date CMS data. All we're missing now is a way to display the preview, enabling the editors to see their content changes from right within the CMS.

In Sanity, we can achieve that using the Iframe Pane plugin. It's a straightforward way to render any external URL as a view inside Sanity's "Studio", the CMS Interface. Check the plugin docs on how to implement it.

The plugin will pass the currently viewed document to a function, and we need to return the URL for the iFrame from that. In our case, that involves looking up the document slug property in a little utility method and combining that relative path with our preview server's domain:

// studio/desk/defaultDocumentNode.js
import { Iframe } from 'sanity-plugin-iframe-pane'
import { schemaTypes } from '../schema'
import { getDocumentPermalink } from '../utils/sanity'

// this function will receive the Sanity "document" (read: page)
// the editor is currently working on. We need to generate
// a preview URL from that to display in the iframe pane.
function getPreviewUrl(doc) {
    // our custom little preview server
    const previewHost = 'https://preview.codista.dev'
    // a custom helper to resolve a sanity document object into its relative URL like "/about"
    const documentURL = getDocumentPermalink(doc)
    // build a full URL
    const url = new URL(documentURL, previewHost)

    // append some query args to the URL
    // rev: the revision ID, a unique string generated for each change by Sanity
    // token: a custom token we use to authenticate the request on our preview server
    let params = new URLSearchParams(url.search)
    params.append('rev', doc._rev)
    params.append('token', process.env.SANITY_STUDIO_PREVIEW_TOKEN)
    url.search = params.toString()

    return url.toString()
}

// this part is the configuration for the Sanity Document Admin View.
// we enable the iFrame plugin here for certain document types
export const defaultDocumentNode = (S, { schemaType }) => {
    // only documents with the custom "enablePreviewPane" flag get the preview iframe.
    // we define this in our sanity content schema
    const schemaTypesWithPreview = schemaTypes
        .filter((schema) => schema.enablePreviewPane)
        .map((schema) => schema.name)

    if (schemaTypesWithPreview.includes(schemaType)) {
        return S.document().views([
            S.view.form(),
            S.view
                // enable the iFrame plugin and pass it our function
                // to display a preview URL for the viewed document
                .component(Iframe)
                .options({
                    url: (doc) => getPreviewUrl(doc),
                    reload: { button: true }
                })
                .title('Preview')
        ])
    }
    return S.document().views([S.view.form()])
}

Aaaand that's it!
Near-instant live previews from right within Sanity studio.

This was quite an interesting challenge, since there are so many moving parts involved. The end result turned out great though, and it was nice to see it could be accomplished without relying on third-party serverless functions.

Please note that this may not be the route to take for your specific project though, as always: your experience may vary! 😉

Andere Beiträge

  • How to build a language switcher in Wagtail / Django

    A switcher enables users to seamlessly transition between different languages. But how can we achieve this functionality in a Wagtail project? We need a way to link pages together.

  • Neon Mode: Building a new Dark UI

    We recently added a darkmode to our website. It's not just white text on a black background though - this one has a few extra tricks up its sleeve. Here's how we built it.