Directus and SEO: Tips & Tricks
Published November 7th, 2023
Search engine optimization (SEO) is an ever-changing but super important part of getting your website in front of visitors.
When using Directus as a Headless CMS, it is incredibly un-opinionated about what you do with your data and content on the frontend, leaving how you build your website up to you.
But if you’re just starting out, being so open-ended can leave you wondering about the best way to handle things like SEO.
In this post, I’ll share a few tips for managing SEO with Directus. I’ll also share some links and resources for some of the most popular frontend frameworks.
Important Assumptions
- You understand basic SEO strategy and terminology.
- You’re familiar with collections, fields, and fetching data from Directus.
Create a Separate Collection for SEO Data
At the end of the day, page titles and meta tags are just data. And you have to store that SEO data for your pages and blog posts somewhere.
For simple sites, you could easily manage all your meta tag data by duplicating fields like title
and meta_description
from one collection to another.
But beyond a couple collections, this approach becomes cumbersome and potentially dangerous. What if you forget to copy one field? What if there’s a typo and the names become inconsistent? These oversights could break your SEO practices and ,at worst, your whole site.
One better option would be creating a single collection to standardize all the SEO data for your content collections:
Create an
seo
collectionmdseo - id (Type: uuid) - title (Type: String, Interface: Input, Note: This item's title, defaults to item.title. Max 70 characters including the site name.) - meta_description (Type: Text, Interface: Textarea, Note: This item's meta description. Max 160 characters.) - canonical_url (Type: String, Interface: Input, Note: Where should the canonical URL for this entry point to.) - no_index (Type: Boolean, Interface: Toggle, Note: Instruct crawlers not to index this item.) - no_follow (Type: Boolean, Interface: Toggle, Note: Instruct crawlers not to follow links on this item.) - og_image (Type: Image, Note: This item's OG image. Defaults to global site OG image. The recommended size is 1200px x 630px. The image will be focal cropped to this dimension.) - sitemap_change_frequency (Type: String, Interface: Input, Note: How often to instruct search engines to crawl.) - sitemap_priority (Type: Decimal, Interface: Input, Note: Valid values range from 0.0 to 1.0. This value does not affect how your pages are compared to pages on other sites, it only lets the search engines know which pages you deem most important for the crawlers.)
seo - id (Type: uuid) - title (Type: String, Interface: Input, Note: This item's title, defaults to item.title. Max 70 characters including the site name.) - meta_description (Type: Text, Interface: Textarea, Note: This item's meta description. Max 160 characters.) - canonical_url (Type: String, Interface: Input, Note: Where should the canonical URL for this entry point to.) - no_index (Type: Boolean, Interface: Toggle, Note: Instruct crawlers not to index this item.) - no_follow (Type: Boolean, Interface: Toggle, Note: Instruct crawlers not to follow links on this item.) - og_image (Type: Image, Note: This item's OG image. Defaults to global site OG image. The recommended size is 1200px x 630px. The image will be focal cropped to this dimension.) - sitemap_change_frequency (Type: String, Interface: Input, Note: How often to instruct search engines to crawl.) - sitemap_priority (Type: Decimal, Interface: Input, Note: Valid values range from 0.0 to 1.0. This value does not affect how your pages are compared to pages on other sites, it only lets the search engines know which pages you deem most important for the crawlers.)
Beyond the basic
title
andmeta_description
, the other fields you add to your SEO collection are totally up to you.Adding the fields within Directus is only one half of the work - you need to use these fields in your frontend within your
<head>
tags and sitemap.For each of your content collections that have a route on your frontend, inside Directus – create a many-to-one (M2O) relationship with the SEO collection.
When fetching your content on the frontend, use the
fields
parameter to expand the SEO data within a single API call.jsimport { createDirectus, rest, readItem } from '@directus/sdk'; const client = createDirectus('directus_project_url').with(rest()); const postId = '234ee-3fdsafa-dfadfa-dfada'; const post = await client.request( readItem('posts', postId, { fields: [ 'title', 'summary', 'content', 'image', { seo: [ 'title', 'meta_description', 'canonical_url', 'no_index', 'no_follow', 'og_image', 'sitemap_change_frequency', 'sitemap_priority', ], }, ], }), );
import { createDirectus, rest, readItem } from '@directus/sdk'; const client = createDirectus('directus_project_url').with(rest()); const postId = '234ee-3fdsafa-dfadfa-dfada'; const post = await client.request( readItem('posts', postId, { fields: [ 'title', 'summary', 'content', 'image', { seo: [ 'title', 'meta_description', 'canonical_url', 'no_index', 'no_follow', 'og_image', 'sitemap_change_frequency', 'sitemap_priority', ], }, ], }), );
4. Then pass that to your frameworks specific method of add SEO metadata within your <head>
tags.
Frontend Framework Metadata Documentation
- Next.js - Dynamic Metadata
- Nuxt - SEO and Meta
- Astro - Layouts
- SvelteKit - SEO
- Remix - meta
- Angular - Meta
Don’t Use Slugs as a Primary Key
The actual url for a page on your website is a key factor for search engine rankings. Because of that, your content editors will want to experiment and adjust urls and slugs for items as needed.
When adding a new collection Directus lets you choose from several types of primary keys for your collection.
- Auto-incremented integer
- Auto-incremented big integer
- Generated UUID
- Manually entered string
I’ve seen lots of folks name the primary key field slug
and use the Manually entered string
. It’s easy to understand. It makes fetching that item easier because you can get the item by its id (the slug) directly instead of constructing a query to match the slug.
But an item’s primary key value can not be changed after they are created, while this protects your database and all of your relationships, it makes it hard to experiment or change URLs if, for example, a product name changes.
To avoid this, use the auto-incremented ids or UUIDs for the primary key id
and create a separate field input for the slug
. You can require uniqueness which means only one item in a collection can have a given slug.
You can no longer use API endpoints or SDK functions for reading a single item, as this relies on using the primary key. Instead, query the whole collection and use Filter Rules to get the single item that has the slug:
import { createDirectus, rest, readItems } from '@directus/sdk';
const client = createDirectus('directus_project_url').with(rest());
const slugFromYourFrontEndFramework = params.slug || router.query.slug // whatever your convention your framework follows;
const posts = await client.request(
readItems('posts', {
filter: {
slug: {
_eq: 'slugFromYourFrontEndFramework',
},
},
limit: 1,
})
);
const post = posts.data[0];
import { createDirectus, rest, readItems } from '@directus/sdk';
const client = createDirectus('directus_project_url').with(rest());
const slugFromYourFrontEndFramework = params.slug || router.query.slug // whatever your convention your framework follows;
const posts = await client.request(
readItems('posts', {
filter: {
slug: {
_eq: 'slugFromYourFrontEndFramework',
},
},
limit: 1,
})
);
const post = posts.data[0];
Use Relationships for Internal Linking
When building a website, you’ll need both links for internal content and links for external content.
One common pattern I’ve noticed is creating string inputs for internal links to other content.
But this can be surprisingly brittle. As soon as the slug for the Contact page changes from /contact-us
to /contact-directus-team
, the link will break and this can really crash your search engine rankings.
Luckily Directus makes a more dynamic approach possible with relationships.
When creating your data model for links to other items from the same or different collections, try using the conditional fields and many to one relationships to build a powerful, resilient way to link items.
Within your content collection, add the following fields for linking. (Note: This example is extremely simplified so you can learn the logic involved. - name these depending on what makes the most sense to you and your use case.)
mdyour_content_collection - link_type (Type: String, Interface: Dropdown, Note: Choices: [ { "text": "Internal - Page", "value": "pages" }, { "text": "Internal - Post", "value": "posts" }, { "text": "External - URL", "value": "external" } ]) - link_label (Type: String, Interface: Input, Note: What label or title is displayed for this link?) - link_page (Type: M20 Relationship, Related Collection: pages, Hidden On Detail, Conditions: [ { "name": "IF link_type === page", "rule": { "_and": [ { "link_type": { "_eq": "pages" } } ] }, "hidden": false, } ]) - link_post (Type: M20 Relationship, Related Collection: post, Hidden On Detail, Conditions: [ { "name": "IF link_type === post", "rule": { "_and": [ { "link_type": { "_eq": "post" } } ] }, "hidden": false, } ]) - link_external_url (Type: String, Interface: Input, Conditions: [ { "name": "IF link_type === external", "rule": { "_and": [ { "link_type": { "_eq": "external" } } ] }, "hidden": false, } ])
your_content_collection - link_type (Type: String, Interface: Dropdown, Note: Choices: [ { "text": "Internal - Page", "value": "pages" }, { "text": "Internal - Post", "value": "posts" }, { "text": "External - URL", "value": "external" } ]) - link_label (Type: String, Interface: Input, Note: What label or title is displayed for this link?) - link_page (Type: M20 Relationship, Related Collection: pages, Hidden On Detail, Conditions: [ { "name": "IF link_type === page", "rule": { "_and": [ { "link_type": { "_eq": "pages" } } ] }, "hidden": false, } ]) - link_post (Type: M20 Relationship, Related Collection: post, Hidden On Detail, Conditions: [ { "name": "IF link_type === post", "rule": { "_and": [ { "link_type": { "_eq": "post" } } ] }, "hidden": false, } ]) - link_external_url (Type: String, Interface: Input, Conditions: [ { "name": "IF link_type === external", "rule": { "_and": [ { "link_type": { "_eq": "external" } } ] }, "hidden": false, } ])
Fields for page, post, and external url are only visible when the related type is selected so there is no confusion about which fields to enter data for. And it also allows you to use that relationship to fetch the proper slug or permalink for posts and pages.
On the frontend, when you are fetching data via the API, use the fields parameter to get the related posts and pages in a single API call.
Then make sure you’re getting the proper url based on the link_type
:
const post = await client.request(
readItem('your_content_collection', 'your_content_item_id', {
fields: [
// Fetch all the other root level fields for your collection **In production, you should only fetch the fields you need**
'*',
'link_type',
'link_label',
'external_url',
// Use object syntax to fetch fields from a relation
{
page: ['id', 'title', 'permalink'],
post: ['id', 'title', 'slug'],
},
],
}),
)
function getUrl(item) {
if (item.link_type === 'pages') {
return item.page.permalink ?? ''
} else if (item.link_type === 'posts') {
return `/blog/${item.post.slug}` ?? ''
} else if (item.link_type === 'external') {
return item.external_url ?? ''
}
return undefined
}
const post = await client.request(
readItem('your_content_collection', 'your_content_item_id', {
fields: [
// Fetch all the other root level fields for your collection **In production, you should only fetch the fields you need**
'*',
'link_type',
'link_label',
'external_url',
// Use object syntax to fetch fields from a relation
{
page: ['id', 'title', 'permalink'],
post: ['id', 'title', 'slug'],
},
],
}),
)
function getUrl(item) {
if (item.link_type === 'pages') {
return item.page.permalink ?? ''
} else if (item.link_type === 'posts') {
return `/blog/${item.post.slug}` ?? ''
} else if (item.link_type === 'external') {
return item.external_url ?? ''
}
return undefined
}
<!-- Inside your template -->
<a href="{getUrl(item)}">{item.link_label}</a>
<!-- Inside your template -->
<a href="{getUrl(item)}">{item.link_label}</a>
Frontend Framework Link Documentation
- Next.js - Link
- Nuxt - NuxtLink
- Astro - Link between pages
- Svelte - Link options
- Remix - Link
- Angular - RouterLink
Add Fields to Control Semantic Elements When Using Dynamic Page Builder
The structure of your content matters a lot for SEO. Crawlers like well structured pages with a clear hierarchy.
For educational items like blog posts or documentation, semantic hierarchy and design usually align well. Most of these items also have a well defined “template” or “layout” on the frontend.
The <h1>
tag contains the title of the article and is the visually the largest header on the page. Other header tags like <h2>
or <h3>
get smaller visually and have less priority for SEO.
But for other items like that are more dynamic like landing pages or homepages, the Builder (Many To Any Relationships) really shine inside Directus. You can let your marketing or content teams build pages on their own with predefined collections or blocks
without involving a developer at all. It also pairs beautifully with the Live Preview feature to allow them to see exactly what the site will look like before publishing.
However, handling the semantic markup you need for proper SEO versus the fact that blocks could be placed anywhere on a page can be a real challenge when developing your site.
Let’s take header tags for example. You need proper semantic hierarchy for SEO. But our hierarchy for SEO doesn’t always equal our visual hierarchy required for good design.
Having the keyword optimized <h1>
tag be the largest visually - is not always ideal.
But in other cases, the <h1>
tag should be the largest visually.
A great solution to this problem can be to create separate fields within the collection that allow the content editor to choose both the proper header tag and the visual size.
Here’s an example.
This provides a ton of flexibility. And with just a little training, editors can create pages that look great on screen and also perform well for SEO.
To render this on the frontend, you’d use dynamic components. Most frontend tools support the concept of dynamic components. The syntax will vary though so consult your frontend framework’s documentation.
Frontend Framework Component Documentation
- Next.js - Dynamic Imports for Components
- Nuxt - Dynamic Components
- Astro - Components - Astro is a bit unique in that you can use components from other frameworks
- Svelte - svelte:component
- Remix - I couldn’t find anything on dynamic components within Remix’s documentation.
- Angular - Dynamic component loader
Implement a Sitemap
Sitemaps are important tools for crawlers like Googlebot to index your site properly. It’s easy to skip over this step when launching a new site, but it’s an important step that makes sure all the pages on your site can be found in search engines.
There’s not much to really manage inside Directus for a sitemap beyond properly creating your content collections. The heavy lifting for a sitemap is on the frontend.
Some frontend frameworks have an official or community-supported sitemap module / plugin. Others have instructions on how to generate a sitemap without the need to another package.
The exact implementation details will vary based on your selected framework but the general approach looks like this.
- Create a function that fetches the items for each collection that has a route on your frontend.
- Loop through those items formatting each as proper xml (or as the specific syntax your plugin requires).
- Create a route like
/sitemap.xml
that returns the XML file.
Here’s an example that’s specific to Nuxt but the logic could be extracted to other frameworks.
// This example is based on Nuxt and uses a third party package.
// Consult your own frontend framework's documentation for how to properly generate a sitemap in their ecosystem.
// /server/routes/sitemap.xml.ts
import { SitemapStream, streamToPromise } from 'sitemap'
import { createDirectus, readItems, rest } from '@directus/sdk'
const directus = createDirectus(directusUrl).with(rest())
export default defineEventHandler(async (event) => {
// Fetch all the collections you want to include in your sitemap
const pages = await directus.request(
readItems('pages', {
fields: ['permalink'],
limit: -1, // Be careful using -1, it will fetch all the items in the collection and could cause performance issues if you have a lot of items
}),
)
const posts = await directus.request(
readItems('posts', { fields: ['slug', { type: ['slug'] }], limit: -1 }),
)
// Create an array of objects with the url you want to include in your sitemap
const urls = []
urls.push(...pages.data.map((page) => ({ url: page.permalink })))
urls.push(
...posts.data.map((post) => ({
url: `/blog/${post.slug}`,
})),
)
const sitemap = new SitemapStream({
hostname: 'https://example.com',
})
// Add each url to the sitemap
for (const item of urls) {
sitemap.write({
url: item.url,
changefreq: 'monthly',
})
}
sitemap.end()
return streamToPromise(sitemap)
})
// This example is based on Nuxt and uses a third party package.
// Consult your own frontend framework's documentation for how to properly generate a sitemap in their ecosystem.
// /server/routes/sitemap.xml.ts
import { SitemapStream, streamToPromise } from 'sitemap'
import { createDirectus, readItems, rest } from '@directus/sdk'
const directus = createDirectus(directusUrl).with(rest())
export default defineEventHandler(async (event) => {
// Fetch all the collections you want to include in your sitemap
const pages = await directus.request(
readItems('pages', {
fields: ['permalink'],
limit: -1, // Be careful using -1, it will fetch all the items in the collection and could cause performance issues if you have a lot of items
}),
)
const posts = await directus.request(
readItems('posts', { fields: ['slug', { type: ['slug'] }], limit: -1 }),
)
// Create an array of objects with the url you want to include in your sitemap
const urls = []
urls.push(...pages.data.map((page) => ({ url: page.permalink })))
urls.push(
...posts.data.map((post) => ({
url: `/blog/${post.slug}`,
})),
)
const sitemap = new SitemapStream({
hostname: 'https://example.com',
})
// Add each url to the sitemap
for (const item of urls) {
sitemap.write({
url: item.url,
changefreq: 'monthly',
})
}
sitemap.end()
return streamToPromise(sitemap)
})
Frontend Framework Sitemap Resources
- Next.js - XML Sitemap Documentation
- Nuxt - Nuxt Simple Sitemap Module
- Astro - @astro/sitemap Integration
- Svelte - Sitemap Package
- Remix - Remix SEO Package
- Angular - I struggled to find a helpful tutorial or sitemap package for Angular.
Give Your Team Control of Redirects
It sucks having to pull yourself away from a fun (or important) project to manually add some redirects for a page. Do yourself a favor and let your content team add and manage redirects within the CMS.
Create a
redirects
collectionmdredirects - id (Type: uuid) - url_old (Type: String, Interface: Input) - url_new (Type: Integer, Interface: Slider) - response_code (Type: String, Interface: Dropdown, Choices: [ { "text": "Permanent (301)", "value": "301" }, { "text": "Temporary (302)", "value": "302" } ])
redirects - id (Type: uuid) - url_old (Type: String, Interface: Input) - url_new (Type: Integer, Interface: Slider) - response_code (Type: String, Interface: Dropdown, Choices: [ { "text": "Permanent (301)", "value": "301" }, { "text": "Temporary (302)", "value": "302" } ])
Add redirects dynamically when building your frontend. This is often done by creating a function to fetch the redirects from your Directus API and then passing those redirects to your frontend framework using a specific syntax inside a config file, plugin, or module.
js// Though it depends on your specific framework, this logic would probably be called during build time import { createDirectus, readItems, rest } from '@directus/sdk' const directus = createDirectus(directusUrl).with(rest()) const redirects = await directus.request(readItems('redirects')) for (const redirect of redirects) { let responseCode = redirect.response_code ? parseInt(redirect.response_code) : 301 // If response code doesn't match what we expect, use 301 if (responseCode !== 301 && responseCode !== 302) { responseCode = 301 } // Add Logic here to add the redirects to a config file or tell your frontend framework how to handle them // ** Your Logic ** }
// Though it depends on your specific framework, this logic would probably be called during build time import { createDirectus, readItems, rest } from '@directus/sdk' const directus = createDirectus(directusUrl).with(rest()) const redirects = await directus.request(readItems('redirects')) for (const redirect of redirects) { let responseCode = redirect.response_code ? parseInt(redirect.response_code) : 301 // If response code doesn't match what we expect, use 301 if (responseCode !== 301 && responseCode !== 302) { responseCode = 301 } // Add Logic here to add the redirects to a config file or tell your frontend framework how to handle them // ** Your Logic ** }
Frontend Framework Redirect Documentation
Don’t Forget Image Alt Text
Often overlooked, image alt text is important for SEO and critical for proper accessibility.
<img src="https://example.com/image.png" alt="An example image of how alt text works" width="500" height="500" />
<img src="https://example.com/image.png" alt="An example image of how alt text works" width="500" height="500" />
A good way to manage image alt tags is on the files themselves as you upload them within Directus.
I prefer using the description
field on files to store alt text. This way you don’t have to input alt text every single time you use this same image in different contexts.
For items like blog posts or articles you might have an image
or featured_image
field so you can have a nice hero image or a thumbnail if shown inside a card.
If you store alt text inside of the image item, you will want to fetch these nested title and description fields within the same API call:
const posts = await client.request(
readItems('posts', {
filter: {
slug: {
_eq: slugFromYourFrontEndFramework,
},
},
fields: ['title', 'date_published', 'summary', 'content', { image: ['id', 'title', 'description']}]
limit: 1,
})
);
const posts = await client.request(
readItems('posts', {
filter: {
slug: {
_eq: slugFromYourFrontEndFramework,
},
},
fields: ['title', 'date_published', 'summary', 'content', { image: ['id', 'title', 'description']}]
limit: 1,
})
);
<!-- Inside your template -->
<img src={post.image.id} alt={post.image.description ?? ""} />
<!-- Inside your template -->
<img src={post.image.id} alt={post.image.description ?? ""} />
Remember that the position of the image id
will change from item.image
to [item.image.id](http://item.image.id)
when you fetch nested fields.
And remember – the actual alt text itself is just as important as rendering it on the page. I won’t dive into details because Moz has a great tutorial on how to write proper alt text here.
And if you’ve got 100s of images without alt text and you’re dreading doing all that work manually, try a boost from this extension - Directus Media AI Bundle - a winner in a recent AI Hackathon submitted by community member Arood. It uses several different AI tools to write image alt text or descriptions for you.
Frontend Framework Image Documentation
- Next.js Images
- Nuxt Images
- Astro Images
- Svelte Images
- Remix - I couldn’t locate any documentation about images on the Remix site.
- Angular Images
Summary
In this post, we’ve covered some SEO best practices when using Directus as a Headless CMS. Hopefully you’ve learnt something new, and can build more robust data models that serve both your users and search engine crawlers.
If these tips were helpful or if you have some of your own you’d like to share, let us know inside our Discord community.