New Aug 28, 2024

Live CMS Previews with Sanity and Eleventy

More Front-end Bloggers All from Max Böck View Live CMS Previews with Sanity and Eleventy on mxb.dev

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 minute.

That doesn’t sound like much, but when you’re in the middle of writing, having to wait that long for every tiny change to become visible can feel excrutiatingly slow. We need a way to render updates on demand, without actually rebuilding the entire site.

Here’s what we’re trying to achieve:

Serverless Functions to the Rescue?

Permalink to “Serverless Functions to the Rescue?”

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

Eleventy Serverless

Permalink to “Eleventy Serverless”

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, 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 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 anwhere from a simple shared webserver to a full-on bells-and-whistles cloud provider, and switching between them is remarkably easy (in essence, you can drag and drop your output folder anywhere and be done with it).

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. 😅

Do-it-Yourself

Permalink to “Do-it-Yourself”

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, we 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 early on.

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

Let’s jump in!

1. On-Demand Building

Permalink to “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 then 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

Permalink to “Listen for preview requests”

We 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(</span><span class="token string">can't resolve URL to input file: </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>url<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">) }

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(</span><span class="token string">can't build preview for URL: </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>url<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">) } } 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

Permalink to “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 = </span><span class="token string">*[_type == "aboutPage"]{...}</span><span class="token template-punctuation string">

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

Perspectives in Sanity

Permalink to “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

Permalink to “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! 😉

Scroll to top