Alan Mooiman

SvelteKit + Contentful

February 17, 2025

I recently moved this blog's data layer from markdown that was stored in the repo to Contentful. I had a few goals in mind:

  • Build a better understanding of the tools I use at my day job so I can be more effective in it by building empathy for internal editors of the site
  • Separate coding environment from publishing environment so that I can more easily publish from different devices
  • Serve pre-rendered HTML to users as quickly as possible
  • Allow me, as an editor, to preview draft changes (for published and unpublished pages) using Contentful's live preview

Pure SvelteKit approach

The site was already built on SvelteKit , so most of the work I needed to do involved changing how data is fetched in +page.server.ts files, with some massaging of +page.svelte files as I worked to make previews and inspector mode function when editing the site.

Given my goals around performance, my first instinct was to serve static (prerendered) pages for most visitors, and then enable some client-side JS when certain query parameters were present.

(Code disclaimer: I don't have TypeScript strict mode enabled yet, I am still using Svelte 4, and haven't gotten around to setting up a GQL client, so there's definitely opportunities to improve the following snippets)

Fetching the data for this approach is pretty straightforward:

Data fetching

src/routes/blog/[slug]/+page.server.ts

import contentfulFetch from '$lib/utils/contentful-fetch'
import { blogQuery } from './blogQuery';

export const prerender = true;

export async function load({ params }) {

const { data } = await contentfulFetch(blogQuery(params.slug));
// Error handling is outside the scope of this demo
return data;
}

Two things worth noting here

  1. With prerender=true, I'm declaring that all pages at this route should be completely assembled at build time
  2. contentfulFetch is mainly a wrapper for a fetch function that wraps in the Contentful GraphQL endpoint and access token

Rendering

src/routes/blog/[slug]/+page.svelte

<script lang="ts">
  import SvelteMarkdown from "svelte-markdown";
  import { blogQuery } from "./blogQuery";
  import { onMount } from "svelte";
  import { page } from "$app/stores";

  export let data;
  $: ({ title, date, unrenderedRichText, markdown } =
    data.blogEntryCollection.items[0] || "");
  $: articleId = data.blogEntryCollection.items[0].sys.id;
  let dateFormat = new Intl.DateTimeFormat("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric",
  });
  let ContentfulLivePreview: any;

  $: getContentfulProps = (fieldId: string) => {
    if (typeof ContentfulLivePreview !== "undefined") {
      return ContentfulLivePreview.getProps({
        entryId: articleId,
        fieldId,
      });
    }
    return {};
  };

  onMount(async () => {
    const previewToken = $page.url.searchParams.get("preview_token");
    if (previewToken) {
      const contentfulPreviewFetch = (
        await import("$lib/utils/contentful-preview-fetch")
      ).default;
      ContentfulLivePreview = (await import("@contentful/live-preview"))
        .ContentfulLivePreview;
      const locale = "en-US";
      ContentfulLivePreview.init({ locale, enableLiveUpdates: true });
      const previewData = await contentfulPreviewFetch(
        blogQuery($page.params.slug, true),
        previewToken,
      );
      data = previewData.data;
      ContentfulLivePreview.subscribe({
        data,
        locale,
        callback: (newData) => {
          data = newData;
        },
      });
    }
  });
</script>

<article data-contentful-asset-id={articleId}>
  <h1 {...getContentfulProps("title")}>
    {title}
  </h1>
  <p {...getContentfulProps("date")}>
    {dateFormat.format(new Date(date))}
  </p>
  <div {...getContentfulProps("markdown")}>
    <SvelteMarkdown source={markdown} />
  </div>
</article>

<style>
  article {
    max-width: 70ch;
    margin: 0 auto;
  }
</style>

This starts by destructuring GraphQL response. This needs to happen here because for the Contentful live updates to work, I can't go back to the server to manipulate the data.

The onMount function checks the query params for a preview_token. If it exists, we'll dynamically import a function that refetches unpublished page data, so we can inject that preview data into the page. We're also dynamically importing @contentful/live-preview so that most users don't have to download/parse the extra code in their pageload.

There's also a reactive getContentfulProps function in +page.svelte that checks if Contentful Live preview is enabled and runs the package to add the appropriate data attributes to DOM nodes so that the Contentful inspector mode works, though that could likely be handled manually/on all nodes all the time without much effective impact.

Wins

  • Builds a static page for every post
  • Allows me to live preview updates as I type them

Problems

  • I can't preview unpublished pages without stripping away a lot of type safety
  • Complexity of implementation, especially if there are multiple page types.

It's important to note that I tried to build an API endpoint that accepts an optional Contentful preview token to do the fetching, but it seems SvelteKit assumes that if an endpoint is being used by a prerendered page.server.ts, it's not being used by anything else and tries to prerender that API route too. This meant I couldn't pass the preview token and get live data, as it would just...silently return the prefetched content. It's frustrating but I kind of understand why they'd do this; it was the lack of clear documentation that was my biggest frustration.

Because this is a solo personal project, I've stopped here for now, but it's far less-than-ideal of an approach for anything professional with multiple developers. Let's take a look at a couple other approaches that might be reasonable in those situations.

Static site approach

Another way to tackle this is similar to the way Gatsby Cloud worked last I checked. I could build a separate instance of the site that uses a different environment variable for the Contentful token to fetch preview data rather than published data.

Pros

  • Less complexity to inject preview data, as there's no conditional data fetching logic required.

Cons

  • Enabling live preview would require either running unnecessary code on production or adding logic in the preview environment, both of which feel fragile
  • We wouldn't be previewing changes on the live domain. This isn't a concern for my site, but at my day job that would be a problem, as various third party scripts depend on the production domain to execute properly.

SSR pages with durable caching

After working on the first approach and considering the second, the opportunity came up to improve our caching and performance in my day job. I took what I'd learned about what works and what doesn't, and came up with this approach, which I'm pretty happy with. Everything (except for the homepage) is dynamically server side rendered, but we've got some aggressive caching in place to ensure resiliency and responsiveness.

The gist of the approach is that in +page.server.ts we check for a preview token parameter. If it's there, we use preview data, otherwise we fetch published content. In +layout.server.ts we're setting the Netlify Cache Control header, unless the preview parameter is present: Netlify-CDN-Cache-Control: public, max-age=60, stale-while-revalidate=86400, durable. This tells Netlify to cache pages for a minute, but it can serve stale pages for up to a day while it revalidates a page's cache on request. The durable bit adds a layer of long-lived ("durable") cache that edge servers check if they don't have a recent copy of a requested page. Because the site has meaningful traffic, this all means in practice that a user almost always gets a fully-rendered page from the cache, while ensuring frequently visited pages get quick updates. I'm pretty happy with how this has been working; Early Sentry performance data showed an improvement of 250-500ms across the board in TTFB, which is absolutely huge.

To improve the editorial experience, we've a utility function that we import into +page.svelte files. We pass it the entry ID and the locale, and inside that function it calls the onMount handler, running some logic similar to what I used in the above example. I haven't tackled live previews, but the editor experience is significantly improved from where we were, with an easy refresh button separating editors from an updated preview.

Pros

  • No extra data fetching logic
  • Significantly improved caching effectively makes this a static site

Cons

  • More useful for higher-traffic sites
  • TBD on how difficult it is to implement live updates

Conclusion

I went into this hoping the first approach would be useful for my day job. Unfortunately, design decisions in SvelteKit are my constraint here, and I didn't want to fight my framework. Understanding the constraints and capabilities of the different parts of my stack and working with them helped me use the tools better to achieve my goals. Establishing these goals up front was helpful in understanding if my tools were the right ones for the job. While SvelteKit may have gotten in my way a bit in this specific situation, I still think it's a good tool for the job given my broader goals of performance and leveraging the web platform where possible. As a result, I'm pretty happy with where the early exploration in my personal project led me in my professional work, and I'm looking forward to bringing some of the same functionality back to my personal site.