Game changer: funda’s journey transitioning to a headless CMS

At funda, we've moved to a modern headless CMS, enhancing content delivery across platforms. Frontend Developer Lars Schuitema and Backend Developer Melvin Zehl share how Contentful has optimized our content management, making development more efficient and effective.

Buying or selling a house can be a stressful process. As a real estate platform, funda plays a key role. One way we do this is by providing the means to offer and view listings on a platform that is accessible, user-friendly, stable and performant. Equally important is our provision of relevant content articles to our users throughout their customer journey, answering all the questions they might have about buying or selling a house, and sharing stories from other people to bring comfort and reassurance.

To enhance our content delivery, we have recently transitioned from a traditional CMS-based approach to a modern headless CMS solution.

See also: How we tackled our major front-end migration to Nuxt 3

Challenges of the past setup

Until recently, funda had a separate content platform, hosted outside our own tech stack. Over the years, we used different systems, the common denominator being a more traditional Content Management System (CMS). Integrating content written by our content marketeers was a tedious task, and there were several issues with this approach:

  • Inflexible marketing experience: Maintaining a consistent brand experience was challenging due to limited styling and component flexibility. Integration with our mobile apps was also poor.
  • Rigid processes: Changes required a full development cycle to be reflected across all platforms, impacting delivery speed.
  • Maintenance challenges: Hardcoding links to a large number of articles across different applications and codebases was cumbersome and prone to errors. Updating links meant making and testing changes in multiple places.

Unlocking potential with a headless CMS solution

Recently, we have transitioned to a more versatile solution in the form of a headless CMS, opting for Contentful after careful evaluation by all stakeholders, including marketing and product teams. We chose Contentful for its API-first architecture, content composability and ability to integrate smoothly with our existing tech stack. This shift towards a headless CMS empowers both our developers and marketeers.

Developers can leverage modern front-end frameworks like Nuxt and Vue.js to seamlessly integrate with our existing codebase. Furthermore, our Flutter mobile applications handle JSON data structures with ease, showcasing its cross-platform adaptability.

Unlike the traditional CMS platforms, Contentful is a headless CMS, decoupling the backend logic of content management from its frontend presentation layer. This architectural separation enables us to extend content capabilities, simplifying content delivery across multiple channels such as mobile apps, without the constraint of managing tools and processes for each channel.

Emphasizing modularity and composability, Contentful allows us to create reusable building blocks and safely make application changes or recompose components as needed. Contentful stores and delivers content, accessed via its APIs.

In essence, regardless of the channel, delivering content to any platform or device becomes as simple as an API call that returns JSON. While building components still requires development effort, our marketing team gains greater autonomy in content management, determining what content appears where and how it is presented.

To illustrate the application of our headless CMS strategy, we will provide a concrete real-world example of how Contentful enhances our content management and delivery capabilities.

The homepage leading the charge

During the migration of our marketing content pages to the new headless CMS, we also rebuilt our homepage. Reviewing the homepage, several sections stood out as ideal candidates for sourcing content directly from the CMS. This approach allows marketers to autonomously manage content and influence its presentation. One prominent feature on the homepage is the featured articles section, which displays selected articles from our content pages.

Additionally, similar components appear across multiple channels, underscoring the need to develop a reusable User Interface (UI) component capable of dynamically fetching and displaying articles from the CMS in various contexts.

Creating a content model for a hybrid layout

The homepage is composed of various sections, some of which rely on data coming directly from the CMS. Because these sections are positioned differently within the layout of the page, using a single content model approach was impractical. To overcome this hurdle we developed a “Page'' content model with distinct fields allocated to specific positions on the page.

Each field can support multiple content types, granting marketers the freedom to determine the content displayed in each section. Utilizing this model, we created a content entry. This entry is identified by a unique entry ID, allowing us to retrieve it using Contentful’s Content Delivery API within our application.

Displaying any content model in any position

During the development of our homepage and integration with the headless CMS, a key requirement was enabling marketers to effortlessly implement changes. In addition to improving the versatility through the content models within the CMS, our aim was to extend this flexibility directly into the codebase. To achieve this, we introduced a versatile component named CMSBlock. This component is deliberately platform-agnostic, designed not to be tied to any particular platform like Contentful, hence its name. This approach makes it easier to migrate to any other CMS platforms in the future if needed.

To display our component on the page, we link the unique identifier (ID) from the content model to a static value within the CMSBlock component. Each static value corresponds to a specific component in the components directory, with names matching the unique identifier (ID) of the content model.

<script setup lang="ts">
import type { Entry } from 'contentful'

const { entryData, contentTypeId } = defineProps<{
    entryData: Entry;
    contentTypeId: string
}>()

let componentName: any = ''

switch (contentTypeId) {
  case 'CMSBlockArticleLink':
    componentName = resolveComponent('CMSBlockArticleLink')
    break
  case 'CMSBlockBanner':
    componentName = resolveComponent('CMSBlockBanner')
    break
  case 'CMSBlockCTA':
    componentName = resolveComponent('CMSBlockCTA')
    break
  case 'CMSBlockFeaturedArticles':
    componentName = resolveComponent('CMSBlockFeaturedArticles')
    break
}
</script>

<template>
  <component
    :is="componentName"
    :entry-fields="entryData?.fields"
  />
</template>

Resolving these components does not determine their position on the page. The CMSBlock is used in multiple parts of the markup, allowing us to place these CMS-specific components wherever needed. Ultimately, the Page Homepage content entry within our CMS specifies which exact content type (e.g. featured articles section) to display in which position on the page. This means marketers can easily switch components around on the page without needing a developer.

Of course, if a component’s new position requires adjustments for spacing or other aesthetical aspects, a developer may still need to step in to ensure everything looks polished. However, this significantly improves the ease of making content changes.

<section class="mt-10 grid gap-6 sm:grid-cols-2 md:grid-cols-3">
    <div class="inspiration-banner min-h-[262px] w-full border border-[#ededed] bg-neutral-10">
        <CMSBlock
        v-if="bannerArea?.fields"
        :entry-data="bannerArea"
        :content-type-id="bannerArea?.sys?.contentType?.sys?.id"
        class="hover:white/50 flex min-h-[262px] flex-col items-center justify-center bg-neutral-10 bg-cover bg-center bg-no-repeat"
        />
    </div>
    <RectangleAd class="advertisement-rectangle" />
</section>

<CMSBlock
    v-if="largeRectangle?.fields"
    class="mt-10"
    :entry-data="largeRectangle"
    :content-type-id="largeRectangle?.sys?.contentType?.sys?.id"
/>

<section class="my-10 grid gap-6 md:my-6 md:grid-cols-3">
    <CMSBlock
        v-if="squareTwo?.fields"
        :entry-data="squareTwo"
        :content-type-id="squareTwo?.sys?.contentType?.sys?.id"
    />
    <CMSBlock
        v-if="squareThree?.fields"
        :entry-data="squareThree"
        :content-type-id="squareThree?.sys?.contentType?.sys?.id"
    />
    <LayerMyAccountNewsletterSubscriptionForm />
</section>

This approach retains developer involvement when managing existing CMS-specific components and developing new ones. It aligns with our objective of empowering marketers while allowing developers to maintain control over markup, styling, tracking and other aspects of a component. This setup supports developers in assisting marketers when necessary.

Determining the need for a shared codebase

Initially, we deliberately avoided moving anything to a shared codebase* while developing the homepage. Our goal was to first determine exactly what we needed to share, in order to prevent unnecessary changes and expedite the homepage release. However, during the integration of Contentful, new candidates emerged, necessitating a shared solution. This included the featured articles section component and a utility for authenticating and fetching entries from the CMS.

As a result, we decided to create a shared codebase in the shape of a Nuxt layer. In the end this involved three steps for both the homepage and the new candidates to use the shared codebase:

  1. Move the “featured articles section” component: We transferred the UI component for the featured articles section from the homepage to the new shared layer. By doing so, we avoided maintaining multiple versions fetching data from the same content model, enabling developers to easily display this component in different parts of our product.
  2. Extract the authentication and fetch utility: We moved the utility to authenticate and fetch entries from the CMS to the shared layer. This includes satisfying defaults such as the tokens required to authenticate, because most apps will authenticate to the same space within the Contentful environment, while still providing the option to connect to a different one.
  3. Create a new content entry: We created a new content entry based on the featured articles content model. Although we could reuse the same entry, our new candidate required different articles and a different number of articles to be displayed, but leveraging the same content model.
import * as contentful from 'contentful'
import type { ChainModifiers, EntryQueries, ContentfulClientApi, CreateClientParams } from 'contentful'
import { encodeToBase64 } from '../helpers/encode'

export function useContentful (options?: {headers?: Record<string, string>, spaces?: Record<string, string>}) {
  const { log } = useLog()
  const contentfulConfig: CreateClientParams = useRuntimeConfig().public.contentful

  // The primary Contentful space used by createClient is the "verticals" space.
  // These other spaces are specified to enable cross-space references using Contentful's space orchestration.
  // The default space for cross-referencing is typically "funda content".
  // If additional spaces are required, we can pass them through the options argument.
  const spaces = options?.spaces

  if (spaces && Object.keys(spaces).length > 0) {
    const encodedTokens = encodeToBase64({ spaces })

    const defaultHeaders = {
      'x-contentful-resource-resolution': encodedTokens
    }

    contentfulConfig.headers = {
      ...defaultHeaders,
      ...(options.headers || {})
    }
  }

  // Due to an issue with the `contentful` package, we are using a temporary workaround.
  // This workaround involves importing `contentful` both as a default import and a named import, which is causing a SyntaxError.
  // We are using the `process.env.NODE_ENV === 'production'` check to conditionally choose the correct `createClient` function.
  // Please refer to the following threads for more information:
  // Contentful issue: https://github.com/contentful/contentful.js/issues/1233, https://github.com/contentful/contentful.js/issues/2225
  // Vite issue: https://github.com/vitejs/vite/issues/2139
  // eslint-disable-next-line import/namespace
  const createClient = contentful.createClient || contentful.default?.createClient || window.contentful?.createClient

  const contentfulClient: ContentfulClientApi<undefined> = createClient(contentfulConfig)

  const fetchEntry = async (entryId: string, queryParams?: EntryQueries<ChainModifiers>) => {
    const result = await useLazyAsyncData(`contentful-${entryId}`, () => contentfulClient.getEntry(entryId, queryParams))

    if (result.error.value) {
      log(`Error fetching entry ${entryId} from contentful: ${result.error.value}`)
    }

    return result
  }

  return { fetchEntry }
}

*In this article, when we refer to a shared codebase, we mean both our web and mobile apps. Currently, we have several live web pages, while our integration with Contentful using Flutter is still in its early stages. The examples given are mainly limited to web applications. However, the same ideas can be applied to our mobile apps.

Space orchestration and cross-space referencing

Using Contentful, you operate within a single organizational environment, yet have the option to create multiple spaces. Initially, we created a single space named “Funda Content” to manage our marketing content articles and associated content models.

However, due to space limitations set by the Contentful platform, we quickly reached the maximum number of content models allowed within our space. This triggered us to rethink and reduce the total number of content models that we created. Yet, as we began integrating Contentful for our new candidates, new and unique content models were required, and we would soon be faced with a blocking issue.

To address this issue, we leveraged Contentful’s space orchestration feature, which enables cross-referencing spaces. Essentially, this allowed us to create a new space called Funda Platform dedicated to our web and mobile apps, separate from our marketing content. For instance, the content model for our featured articles section would be moved to the Funda Platform space, while the actual content is stored in the Funda Content space. 

To implement this solution, we needed to update our shared Nuxt layer to accommodate an optional spaces object specifying the spaces we want to cross-reference. The originally connected space remains essential and is referred to as the main space. All additional spaces we want to reference are included in a spaces object, which is encoded and passed as a value in the x-contentful-resource-resolution request header.

export function useContentful (options?: {headers?: Record<string, string>, spaces?: Record<string, string>}) {
  const { log } = useLog()
  const contentfulConfig: CreateClientParams = useRuntimeConfig().public.contentful

  // The primary Contentful space used by createClient is the "verticals" space.
  // These other spaces are specified to enable cross-space references using Contentful's space orchestration.
  // The default space for cross-referencing is typically "funda content".
  // If additional spaces are required, we can pass them through the options argument.
  const spaces = options?.spaces

  if (spaces && Object.keys(spaces).length > 0) {
    const encodedTokens = encodeToBase64({ spaces })

    const defaultHeaders = {
      'x-contentful-resource-resolution': encodedTokens
    }

    contentfulConfig.headers = {
      ...defaultHeaders,
      ...(options.headers || {})
    }
  }

This approach not only facilitates scalability overcoming limitations on the number of content models, but also segregates content concerns between marketing pages and other applications.

A significant leap forward

Funda’s transition to a headless CMS marks a significant leap forward in our ability to deliver content across multiple platforms. By embracing a headless solution, we have empowered our team to innovate more freely, unshackled from the constraints of a traditional CMS. This shift not only improves our content management quality and delivery speed, but also elevates the user experience by ensuring consistent, high-quality content within our platform.

During the integration process, we faced several challenges. As new candidates for the shared solution emerged, such as the featured articles section component and a utility for authentication and fetching entries, we moved these to a Nuxt layer.

We also had to manage the limitations on the number of content models within a single Contentful space. To overcome this, we utilized Contentful’s space orchestration feature, enabling us to create separate spaces for different contexts and implement cross-space referencing. These solutions ensured a robust and efficient integration, enhancing our content delivery capabilities and setting a strong foundation for future developments.

Looking ahead, we are expanding our use of the headless CMS across more areas of our platform, including our mobile apps, and continue to support our marketers in delivering high-quality content.

See also: Game changer: Why we implemented an advertising UI library (and how)

Question?
Do you have a burning question for Lars and Melvin after reading this blog? Feel free to reach out to them via email.