Skip to main content

How to create a content builder using Craft CMS

Raygun magazine covers

I've always wanted web design to be more like print design.

My roots are in graphic design. I started out using QuarkXpress and Adobe Photoshop sometime around 1990. When the mid-90s rolled around, I immediately jumped on board building websites, but I felt like I was constantly beating my head against the wall trying to create web pages that looked like print. You may not remember Raygun magazine, but this iconic alternative rock mag was my design inspiration and what I aspired to achieve in web design. Sadly, creating beautiful design on the early web was a never ending nightmare. We were constantly battling with browser disparities, bandwidth, overly complex table based layouts, and a lack of good development tools. I hoped that someday the massive amounts of code required to build a website would fade away, and websites would created in the same way that we designed pages for print. Fast forward to 2019 and we are still writing tons of code. However, the dev tools are much better, browsers are more consistent, and features like grid and flexbox layout have made it possible to achieve great designs.

Ray gun magazine
David Carson, the designer of Raygun magazine, exploited the tics of Macintosh computer better than anyone in the early 90s.

At this point you may be wondering why I'm talking about graphic design and waxing poetic about Raygun magazine instead of talking about a content builder? Simply put, my goal as a Craft CMS developer is to hand over a tool (like the content builder) that allows my client to create beautiful design. I want web page design to be easy and fun so they are more inclined to create content. While we developers are still writing tons of code, our clients should never be exposed to the madness behind the scenes!

Content Builder Overview

I'm not the first person to write about a Craft content builder and I'm definitely not trying to take credit for the concept. There are plenty of other examples that you may want to explore. I'm offering my unique approach in hopes that it may inspire your next project.

In case you're unfamiliar with the concept of a content builder, I'll give you a brief explanation. With the Matrix field in Craft, you can embed any of the other field types except for another Matrix field. Since sub-fields of a Matrix field can be drag-drop arranged in an entry, you can rearrange the content on your page however you like!

My original content builder was created using the native Matrix field with SuperTable. I started with this approach because it was free, but why did I use SuperTable? Sometimes you may need to nest an element like a button in your parent element. The button may have several sub-elements like a link, background color, target, etc. To avoid confusion in the publishing interface, the button should ideally appear as a sub-group of objects, so combining them in a SuperTable makes sense. If a Matrix field inside of a Matrix field was possible in Craft, we could do away with SuperTable. As a side note, it looks like Matrix in Matrix will be added to Craft 4.0.

Sample builder element
The button in this content builder element requires various sub-fields including link, rel, background color, and target.

You may be wondering why I referenced the Matrix + SuperTable as my "original" content builder solution? After developing my content builder using M + ST field types, I came to a daunting conclusion. If I wanted to animate any or all of the content builder blocks I would need to repeat the same animation fields in every block. The graphic below should explain my conundrum a little better:

Matrix conundrum
For each Matrix block type added, three animation sub-fields need to be added.

For each block created in the Matrix above, I need to add the three unique animation fields – but they are all the same. Repeating the same animation fields in each Matrix block may not be a big deal if you only have two or three block types. But I currently have ten different blocks, so I was facing the absolute misery of creating thirty fields instead of just three! It made my OCD flare up 😜, and I needed to find a DRY solution!

I thought back to a conversation with a colleague. I talked about my content builder solution and he mentioned that he uses NEO instead of nesting SuperTable fields in Matrix. At the time, I was trying to avoid using paid plugins, and besides, I had already written my whole content builder using M + ST. But faced with the untenable chore of repeating the same fields over and over, I thought it was time to reconsider NEO.

NEO solved multiple issues for me

First, NEO allowed me to reuse the same field across all content blocks. NEO is set up like Craft's field layout manager, so the interface is familiar. I started by creating a field group named "Content Builder Elements". These fields include rich text fields, lightswitches, dropdowns, assets and more.

Next, I created a NEO field and within that field I created all of my content blocks. Content blocks include "content width image", "full width video", "text", "blockquote" and more.

After I created my content blocks, I created tab structures within each block to organize fields. By default my tabs are "Content", "Margins" and "Animation".

Finally, I included the required fields for each content block within their respective tabs. This is an example of my setup:

Content builder with neo
Using NEO, you can create content blocks containing any of your fields.

Not only is this dryer solution, but it also means that the template code to render the element is the basically same for every template. I have not yet moved the repeated chunks of code into macros, but I plan to optimize that soon.

Which brings me to another issue resolved by NEO – a cleaner and more organized publishing interface. NEO's tab structure is much easier for content publishers to understand:

Content builder publishing interface
The tab structure in NEO allows for better organization of content within each block.

I question why the functionality of NEO is not native functionality in Craft? Hopefully we will see this in future versions of Craft.

Organizing the templates

I've played around with different ways of organizing my templates. I refer to each block type as a "module" in my template naming scheme. The goal was to create a single entry point for all modules so that I can call the content builder in the same way for every template. This is my file layout:

templates
    _content-builder
        _includes
            _moduleBuilder.twig
        _moduleBlockQuote.twig
        _moduleCode.twig
        _moduleCta.twig
        _moduleEmbeddedAsset.twig
        _moduleHalfHalf.twig
        _moduleImageContent.twig
        _moduleInfoBlock.twig
        _moduleText.twig
        _moduleVideoBg.twig
    _layout
        _base.twig
    _blog
        index.twig
web
    assets
        css
            app.css
            moduleBlockquote.css
            moduleCode.css
            moduleCta.css
            moduleEmbeddedAsset.css
            moduleHalfHalf.css
            moduleImageContent.css
            moduleImageFull.css
            moduleInfoBlock.css
            moduleText.css
            moduleVideoBg.css
        js
            prism.js
Directory & file Layout

I'll start with the base layout template, templates/_layout/_base which is my overall page wrapper:

<!DOCTYPE html>
<html lang="en-US">
<head>
    <title>Content Builder | Vaughn D. Taylor</title>
    <link rel="stylesheet" href="{{ mix('css/app.css') }}">
</head>
<body class="{{ sectionClass | default(null) }}{{ entryClass | default(null) }}">

    <main id="main" class="main">

        {% block main %}{% endblock %}

    </main>

</body>
</html>
Template: _base.twig

I'm sure that the base template is very familiar to you, but I just want to make sure you understand the all the working parts.

Next, is my blog template, templates/blog/index which extends my base template:

{% extends "_layout/_base" %}

{% set sectionClass = craft.app.request.segments|first != '' ? craft.app.request.segments|first : 'home' %}
{% set entryClass = craft.app.request.getSegment(2) != '' ? ' entry' : '' %}

{% block main %}

    {% include '_content-builder/_includes/_moduleBuilder' %}

{% endblock %}
Blog template: index.twig

Note that I'm setting a few classes that will be added to the body using ternary operators. I like to do this in case I need to create a special exception for styling that applies only to specific section or type of entry.

Within the main block, I include the entry point for the module builder. Let's have a look at the full module builder, templates/_content-builder/_includes/_moduleBuilder.twig, then I'll break it down below:

{# EAGER LOAD MODULES #}
{% set modules = entry.contentBuilder
    .with([
        'moduleBlockquote',
        'moduleCode',
        'moduleCta',
        'moduleEmbeddedAsset',
        'moduleHalfHalf',
        'moduleImageContent',
        'moduleImageFull',
        'moduleInfoBlock',
        'moduleText',
        'moduleVideoBg'
    ])
    .level(1)
    .all()
%}

{# LOOP THROUGH MODULES #}
{% for module in modules %}
    <div class="{{ module.type }}">
        {% include '_content-builder/_' ~ module.type %}
    </div>
{% endfor %}

{# MAKE A NEW ARRAY WITH MODULES #}
{% set moduleArray = [] %}

{# REMOVE DUPLICATE MODULE TYPES FROM ARRAY #}
{% for moduleType in modules %}
    {% if moduleType.type not in moduleArray %}
        {% set moduleArray = moduleArray|merge([moduleType.type]) %}
    {% endif %}
{% endfor %}

{# CREATE EMBEDDED CSS FOR EACH MODULE IN ARRAY #}
{% for moduleCSS in moduleArray %}
    {{ craft.mix.withTag('css/' ~ moduleCSS ~ '.css', true) | raw }}
{% endfor %}

{# CREATE EMBEDDED JS FOR moduleCode IN ARRAY #}
{% for moduleJS in moduleArray %}
    {% if moduleJS.handle == 'moduleCode'  %}
        {{ craft.mix.withTag('js/prism.js') | raw }}
    {% endif %}
{% endfor %}
Module builder entry point: _moduleBuilder.twig

The first thing I do is eager load all of the modules in the content builder. If you're not familiar with eager loading in Craft, it basically uses the with criteria parameter to tell Craft which sub-elements you’re going to be needing in advance, so that it can fetch them all up front, in as few queries as possible. The level(1) parameter is specific to NEO, so if you're not using NEO you will not use this.

{# EAGER LOAD MODULES #}
{% set modules = entry.contentBuilder
    .with([
        'moduleBlockquote',
        'moduleCode',
        'moduleCta',
        'moduleEmbeddedAsset',
        'moduleHalfHalf',
        'moduleImageContent',
        'moduleImageFull',
        'moduleInfoBlock',
        'moduleText',
        'moduleVideoBg'
    ])
    .level(1)
    .all()
%}
Eager loading of modules

Next, I loop through all of the modules creating a div with the class that matches the module.type name, and then I include the module from the _content-builder directory. Calling the module.type (which is the module's block name) means that I can segregate the code for each module into its own file.

{# LOOP THROUGH MODULES #}
{% for module in modules %}
    <div class="{{ module.type }}">
        {% include '_content-builder/_' ~ module.type %}
    </div>
{% endfor %}
Looping through modules using module.type

Just make sure to name your module template the same as your block handle. For example:

Content builder block naming
The block handle should match my the template name and CSS file for your module.

There are other ways to retrieve the blocks, but I feel like this is the cleanest way. Originally, I was using switch with the module type handle as the comparison to loop through the blocks, but it's a much messier solution than the small for loop above.

Next I'm creating an array of the all the modules that were loaded on the page which I will be using to generate embedded CSS and JS for each module:

{# MAKE A NEW ARRAY WITH MODULES #}
{% set moduleArray = [] %}

{# REMOVE DUPLICATE MODULE TYPES FROM THE FULL ARRAY OF OBJECTS LOADED ON THE PAGE #}
{% for moduleType in modules %}
    {% if moduleType.type not in moduleArray %}
        {% set moduleArray = moduleArray|merge([moduleType.type]) %}
    {% endif %}
{% endfor %}

{# CREATE EMBEDDED CSS FOR EACH MODULE IN CLEAN ARRAY #}
{% for moduleCSS in moduleArray %}
    {{ craft.mix.withTag('css/' ~ moduleCSS ~ '.css', true) | raw }}
{% endfor %}

{# CREATE EMBEDDED JS FOR moduleCode IF IT WAS INCLUDED IN THE ARRAY #}
{% for moduleJS in moduleArray %}
    {% if moduleJS.handle == 'moduleCode'  %}
        {{ craft.mix.withTag('js/prism.js') | raw }}
    {% endif %}
{% endfor %}
Arrays used to embed CSS and JS

First, take note that I'm using the Craft Mix plugin to output the CSS and JS. I do this because it there are options available in the plugin which make it easy to output embedded code.

But why use embedded code instead of compressing all of the CSS into a single external file? You'll never know how many modules are included on each page – one page could use a single module, and the next page could use every module. Also, external CSS can block rendering of the page until it's completely loaded which may become an issue if your CSS file is large. The obvious downside of my technique is that I cannot take advantage of browser caching for a single CSS file. However, I do wrap the full content area of each page using {% cache %}. Either way you choose to do this is up to you – this is just my technique.

Let's continue with a template used to render a single module, templates/_module-builder/_moduleText.twig:

{% set attributes = {
    class: [
        'moduleText__wrapper',
        module.intro ? 'mod--intro',
        module.backgroundColor ? module.backgroundColor : '',
        module.removeMargin ? 'mod--no-margin',
        module.marginTop != 'null' ? module.marginTop,
        module.marginRight != 'null' ? module.marginRight,
        module.marginBottom != 'null' ? module.marginBottom,
        module.marginLeft != 'null' ? module.marginLeft
    ]
} %}

{% if module.animationType is not empty %}
    {% set attributesAnimation = {
        data: {
            'sal': module.animationType != '' ? module.animationType : 'fade',
            'sal-delay': module.animationDelay != '' ? module.animationDelay : '400',
            'sal-easing': module.animationEasing != '' ? module.animationEasing : 'ease'
        }
    } %}
{% endif %}

<div{{ attr(attributes) }}{% if attributesAnimation is defined %}{{ attr(attributesAnimation) }}{% endif %}>
    {{ module.richText }}
</div>
Module template for text: _moduleText.twig

You may not be familiar with the way I'm setting the attributes here because the attr() function is completely new in Craft 3.2. You can read more about it on CraftSnippets, but basically it allows us to avoid jamming all the attributes for an element into a single, unreadable line. In the template above, I'm breaking the class and data attributes out into two different variables because I need to output the data attributes for my animation conditionally. I don't want to end up with empty data attributes if animation is not chosen for the module.

If you haven't yet tried to create a content builder, I recommend you do. Your clients will be thrilled at the results! If you have any questions, please feel free to reach out to me on Discord @vaughndtaylor.

But, before I go...

I want to show you a very tricky module, templates/_content-builder/_moduleHalfHalf.twig. First is the rendered end result followed by the template code (good luck trying to unravel that mystery!)

Lego building sm

We all need a content builder!

While we may not all agree on the best way to create a content builder, we can all agree it's a necessity.

{% if module.animationType is not empty %}
    {% set animationType = module.animationType %}
    {% set animationDelay1 = module.animationDelay|number %}
    {% set animationDelay2 = module.animationDelay|number * 2 %}
    {% set animationDelay3 = module.animationDelay|number * 3 %}
    {% set animationEasing = module.animationEasing %}
{% endif %}

{% set image = module.image.one() %}
{% if image is not empty %}

    {% set transformedImages = craft.imager.transformImage(image,
        [
            { format: 'jpg', width: 600, height: 400, jpegQuality: 72 },
            { format: 'jpg', width: 400, height: 267, jpegQuality: 55 }
        ],
        {
            position: image.getFocalPoint(),
            sharpen: true,
            interlace: 'partition'
        }
    ) %}

    {% set blurredImage = craft.imager.transformImage(image,
        {
            format: 'jpg',
            width: 800,
            height: 600,
            jpegQuality: 60,
            position: image.getFocalPoint(),
            interlace: 'partition',
            effects: {
                modulate: [15, 150, 66]
            }
        }
    ) %}

{% endif %}

{% set attributesWrapper = {
    class: [
        'moduleHalfHalf__wrapper',
        module.removeMargin ? 'mod--no-margin',
        module.marginTop != 'null' ? module.marginTop,
        module.marginRight != 'null' ? module.marginRight,
        module.marginBottom != 'null' ? module.marginBottom,
        module.marginLeft != 'null' ? module.marginLeft,
        module.backgroundColor ? module.backgroundColor,
        module.maxViewportHeight ? module.maxViewportHeight
    ],
    style: {
        'height': module.maxViewportHeight ? 'calc(100vh - var(--nav-bar-height))' : 'auto',
        'background-image': module.backgroundImage|length ? 'url("' ~ module.backgroundImage[0].url ~ '")' : 'none',
        'background-position': module.backgroundPosition ? module.backgroundPosition : 'unset',
        'background-size': module.backgroundSize ? module.backgroundSize : 'unset',
        'background-repeat': module.backgroundRepeat ? 'repeat' : 'no-repeat'
    }
} %}

{% set attributesSectionLeft = {
    class: [
        'moduleHalfHalf__section--left',
        module.positionThisContentLeft ? 'content--left' : ''
    ]
} %}

{% set attributesSectionRight = {
    class: [
        'moduleHalfHalf__section--right',
        module.positionThisContentLeft ? '' : 'content--right'
    ]
} %}

{% set attributesInnerBorder = {
    class: [
        'moduleHalfHalf__inner-border',
        module.borderColor ? module.borderColor : '',
        module.positionThisContentLeft ? 'mod--inner-border-right' : 'mod--inner-border-left'
    ],
    style: {
        'background-image': module.includeBackgroundInUnderlay ? 'url("' ~ blurredImage.url ~ '")' : 'none',
        'background-size': 'cover',
        'background-repeat': 'no-repeat'
    }
} %}

{% if module.animationType is not empty %}
    {% set attributesAnimation1 = {
        data: {
            'sal': animationType != '' ? animationType : 'fade',
            'sal-delay': animationDelay1 ? animationDelay1 : '400',
            'sal-easing': animationEasing ? animationEasing : 'ease'
        }
    } %}
    {% set attributesAnimation2 = {
        data: {
            'sal': animationType != '' ? animationType : 'fade',
            'sal-delay': animationDelay2 ? animationDelay2 : '400',
            'sal-easing': animationEasing ? animationEasing : 'ease'
        }
    } %}
    {% set attributesAnimation3 = {
        data: {
            'sal': animationType != '' ? animationType : 'fade',
            'sal-delay': animationDelay3 ? animationDelay3 : '400',
            'sal-easing': animationEasing ? animationEasing : 'ease'
        }
    } %}
{% endif %}

<div{{ attr(attributesWrapper) }}>

    <div class="moduleHalfHalf__inner-wrapper{% if module.contained %} mod--container{% endif %}{% if module.emphasize %} mod--emphasize{% endif %}">

        <div class="moduleHalfHalf__content-wrapper">

            <div
            {% if not module.positionThisContentLeft %}
                {{ attr(attributesSectionLeft) }}
                {% if attributesAnimation1 is defined %}
                    {{ attr(attributesAnimation1) }}
                {% endif %}
            {% else %}
                {{ attr(attributesSectionRight) }}
                {% if attributesAnimation1 is defined %}
                    {{ attr(attributesAnimation1) }}
                {% endif %}
            {% endif %}>
            {% if image is not empty %}
                <figure class="moduleHalfHalf__figure">
                    <img itemprop="image" src="{{ craft.imager.placeholder({ width: 160, height: 90 }) }}" sizes="100vw" srcset="{{ craft.imager.srcset(transformedImages) }}" alt="{{ module.image[0].title }}" class="moduleHalfHalf__img lazy">
                </figure>
            {% else %}
                <div class="content--section">
                    {% if module.convertToQuote %}
                    <blockquote class="moduleHalfHalf__blockquote">
                        <span class="moduleHalfHalf__quotes">
                            <svg xmlns="http://www.w3.org/2000/svg" width="75.557" height="21.479" viewBox="0 0 75.557 21.479">
                                <g id="Group_180" data-name="Group 180" transform="translate(876.261 257.401)">
                                    <path id="Path_117" data-name="Path 117" d="M-876.261-245.52c0-8.545,4.565-11.647,11.881-11.881l.878,4.272c-3.921.41-5.853,2.341-5.678,5.5h4.332v11.413h-11.413Zm15.568,0c0-8.545,4.565-11.647,11.822-11.881l.936,4.272c-3.921.41-5.853,2.341-5.736,5.5h4.331v11.413h-11.354Z" fill="rgba(88,89,91,0.2)"/>
                                    <path id="Path_118" data-name="Path 118" d="M-811.623-240.087c3.922-.468,5.853-2.341,5.678-5.5h-4.332V-257h11.413v9.306c0,8.486-4.566,11.647-11.882,11.881Zm15.51,0c3.922-.468,5.853-2.341,5.736-5.5h-4.331V-257h11.354v9.306c0,8.486-4.565,11.647-11.822,11.881Z" transform="translate(-17.35 -0.107)" fill="rgba(88,89,91,0.2)"/>
                                </g>
                            </svg>
                        </span>
                        {{ module.simpleText2 }}
                        {% if module.attribution %}
                            <footer class="moduleHalfHalf__blockquote--footer">
                                &mdash; {{ module.attribution }}
                            </footer>
                        {% endif %}
                    </blockquote>
                    {% else %}
                        {{ module.simpleText2 }}
                    {% endif %}
                </div>
            {% endif %}
            </div>

            <div
            {% if module.positionThisContentLeft %}
                {{ attr(attributesSectionLeft) }}
                {% if attributesAnimation2 is defined %}
                    {{ attr(attributesAnimation2) }}
                {% endif %}
            {% else %}
                {{ attr(attributesSectionRight) }}
                {% if attributesAnimation2 is defined %}
                    {{ attr(attributesAnimation2) }}
                {% endif %}
            {% endif %}>
                <div class="moduleHalfHalf__padding">
                    {% if module.subheader %}<h4 class="moduleHalfHalf__subheader">{{ module.subheader }}</h4>{% endif %}
                    <h2 class="moduleHalfHalf__header">{{ module.header }}</h2>
                    {% if module.simpleText is not empty %}
                        <div class="moduleHalfHalf__text">
                            <p>{{ module.simpleText }}</p>
                        </div>
                    {% endif %}
                    {% if module.buttonBuilder|length %}
                        <div class="button__row">
                        {% set buttons = module.buttonBuilder.all() %}
                        {% for button in buttons %}
                            <a class="{{ button.btnSize}} {{ button.btnType}} mod--a__in-left" href="{% if button.btnInternalLink|length %}{% set buttonLinks = button.btnInternalLink.all() %}{% for buttonLink in buttonLinks %}{{ buttonLink.url }}{% endfor %}{% else %}{{ button.btnExternalLink }}{% endif %}"{% if button.btnBlank %} target="_blank"{% endif %}{% if button.btnNoFollow %} rel="nofollow"{% endif %}>{{ button.btnText }}</a>
                        {% endfor %}
                        </div>
                    {% endif %}
                </div>
            </div>

            <div
            {{ attr(attributesInnerBorder) }}
            {% if attributesAnimation3 is defined %}
                {{ attr(attributesAnimation3) }}
            {% endif %}>
                <span class="moduleHalfHalf__inner-border-top-cap"></span>
                <span class="moduleHalfHalf__inner-border-bottom-cap"></span>
            </div>

        </div>

    </div>

</div>
Module template for half/half: _moduleHalfHalf.twig
scroll to top icon