5.4 Headless Bundles: Content That Is Not Directly Rendered
Right, let’s talk about the digital equivalent of a ghost: content that exists, has substance, but you can’t actually see it on its own. We call these Headless Bundles. The name sounds like a cryptic metal band, but the concept is one of Craft’s most powerful and, frankly, most misused features.
Think of your site’s structure as a tree. Most entries are leaves—they have a specific URL (/blog/my-great-post) and render a page. A Headless Bundle is a branch. Its job isn’t to be a leaf you look at; it’s to hold other leaves (or other branches!) together. It’s an organizational parent, not a final destination. The most common use case is for a headless CMS setup, where Craft is only serving data via GraphQL or REST, not rendering Twig templates. But even in traditional sites, they’re invaluable for grouping content you’ll only ever reference indirectly, like a team of authors, a product category, or a set of legal documents.
The Nuts and Bolts of a Headless Section
You create one just like any other section. The magic is in the settings. When you choose “Headless” for the section type, Craft does two immediate things: it grays out the “Template” setting (because, obviously, there isn’t one) and it slaps a big “This section is not available publicly” warning on the section’s primary URL setting. It’s telling you, “I will not be building a page for these entries. I promise.”
Here’s the kicker, and the first place people get tripped up: headless entries still have slugs. They need them! The slug is their unique identifier within the structure, especially for the Structure section type. It’s how you reference them in relations, and it’s absolutely critical for headless CMS APIs where the slug might be part of a GraphQL query. The slug just doesn’t get used to generate a public-facing URL.
{# This is a common pitfall. You WILL have entry data, but linking to its non-existent URL is a bad time. #}
{% set teamMember = craft.entries.section('team').one() %}
{# DON'T: This will try to generate a URL and probably fail or give you something weird #}
<a href="{{ teamMember.url }}">Our Team</a>
{# DO: Link to a dedicated, publicly rendered page that uses this headless entry's data #}
<a href="{{ url('about/team') }}">Our Team</a>
{# Or, if you're using it for a headless API, the slug is your key #}
{% set teamData = craft.entries.section('team').all() %}
{# You'd then output this teamData as JSON, and the consumer uses the slugs to identify everything #}
Why You’d Use This (Besides the Obvious)
Sure, the headless CMS use case is the headline act. But even on a normal site, headless sections clean up your content model beautifully. Imagine an “Awards” section. You don’t want a page for every “Best Coffee Mug 2022” award; that’s absurd. You just want to list them on your company’s About page. Making Awards a headless section prevents you or a client from accidentally putting those entries in the nav or sitemap. It enforces a single source of truth without cluttering your public URL space.
It also future-proofs you. If you decide later that each award should have its own page, you can change the section type from Headless to Channel, assign a template, and—boom—all your entries now have URLs. The content was already there, perfectly structured and waiting.
The Gotchas and How to Avoid Them
The biggest “oh crap” moment is trying to use entry.url in your Twig code. Craft will try to generate a URL for a headless entry and will return null, or in some older versions, might even throw an error. Your templates will break in subtle ways. The best practice is to be militant about checking section types before assuming an entry has a URL.
{# A robust way to handle a list of entries that might mix headless and public ones #}
{% for entry in entries %}
<article>
<h3>{{ entry.title }}</h3>
<p>{{ entry.summary }}</p>
{# Check if this entry type actually has a URL before trying to link to it #}
{% if entry.getSection().type != 'headless' and entry.url %}
<a href="{{ entry.url }}">Read More</a>
{% endif %}
</article>
{% endfor %}
Another common pitfall is with element queries. When you use .id(), .url(), or other reference methods, Craft has to execute the query. If you’ve got a headless entry in there, it can cause unexpected null values. Always be aware of what sections you’re querying.
In short, Headless Bundles are Craft’s way of giving you a dedicated, powerful tool for organizational content. They prevent URL bloat, enforce good content architecture, and are the bedrock of any decoupled frontend. Just remember: they’re the backstage crew, not the rock stars. Don’t ask them to perform on the main stage.