Loading and bundling content and assets from Directus in Astro
Directus and Astro are both great tools to set up a website. We'll go over how to set up to loading content from Directus, and bundling assets so Directus stays a build-time-only dependency, using a simple custom Astro integration.
Prerequisites
I’ll be using a minimal setup for both Astro and Directus, and go through everything we need. In case you’re not familiar with either of them, I highly recommend looking into them.
For Astro I ran npm create astro@latest and used the blog template, this gives us a basic blog setup and some pages to work with. After the project is set up, run npm install @directus/sdk to install the Directus JavaScript SDK. This will allow us to easily query Directus without having to write much code ourselves.
For Directus, I used this Docker Compose stack to run it locally:
services:
directus:
image: directus/directus:12.0.2
ports:
- "8055:8055"
volumes:
- /directus/database
- /directus/uploads
- /directus/extensions
environment:
SECRET: "replace-with-random-value"
DB_CLIENT: "sqlite3"
DB_FILENAME: "/directus/database/data.db"
WEBSOCKETS_ENABLED: "true"
PUBLIC_URL: "http://localhost:8055/"
LICENSE_KEY: ""
docker-compose.yml
Note that this is very much not a production-ready setup, but it is the simplest way to get Directus running locally without much hassle.
With the setup done, we can run npm run dev to start Astro and docker compose up -d to start Directus, which should now be available on localhost:4321 and localhost:8055 respectively.
Next, let’s replace the home page that the sample project comes with with some content that is loaded from Directus.
Creating the collection
In Directus, we can walk through the setup wizard, and create our first collection. We’ll set up a singleton collection called “Home”:
Add two fields to this model, a “header” image, and a “content” WYSIWYG editor field. You can also use markdown here (or any other format), but the markdown-to-html conversion would require some extra steps, which is out of scope for this post. Add some content, and the result should look something like this:
This gives us both an image and some text to work with in Astro, but before we move on, there’s one more piece of setup to talk about.
Authentication
By default, nothing in Directus is publicly accessible. However, for simplicity in this setup, I’ll make all collections and assets we create public, so we don’t have to worry about authentication in the code. This is done by navigating to the settings, “Access Policies”, “Public”, and adding the relevant collections with the “Read” action.
When setting up a proper production deployment, you’ll likely want to set up authentication with a token for Astro, otherwise people could directly scrape your Directus instance. With that done, let’s really get into the code.
Loading content in Astro
First, in src/consts.ts add this line:
export const CMS_URL = 'http://localhost:8055';
This URL should point to your Directus setup. Next, create src/directus.ts. Here we can set up the Directus client, models, and any helper functions we might need:
import { createDirectus, readSingleton, rest } from '@directus/sdk';
import { CMS_URL } from './consts.ts';
interface Home {
header: string;
content: string;
}
interface Schema {
Home: Home;
}
const directus = createDirectus<Schema>(CMS_URL).with(rest());
export function getHome(): Promise<Home> {
return directus.request(readSingleton('Home'));
}
src/directus.ts
I like creating functions to access the various collections (like getHome here), which I can then use in the various pages. This way I can also ensure all fields and relations are loaded properly, and I don’t have to worry about that in different places.
You may note that the type for header - which is an image - is string here. That is because that contains the image id, not any actual image data. So to ensure we can properly display this, we will also need to create a function that turns that id into an image URL:
export function getImageUrl(id: string): string {
return `${CMS_URL}/assets/${id}`;
}
Now in our index file we can use the getHome function to load the content from Directus, using the result of getImageUrl as an image source, and the set:html attribute for our content. The complete file should look something like this:
---
import BaseHead from '../components/BaseHead.astro';
import Footer from '../components/Footer.astro';
import Header from '../components/Header.astro';
import { SITE_DESCRIPTION, SITE_TITLE } from '../consts';
import { getHome, getImageUrl } from '../directus.ts';
const home = await getHome();
---
<!doctype html>
<html lang="en">
<head>
<BaseHead title={SITE_TITLE} description={SITE_DESCRIPTION} />
</head>
<body>
<Header />
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
<img src={getImageUrl(home.header)} alt="" width="600" />
<main set:html={home.content} />
</div>
<Footer />
</body>
</html>
src/index.astro
That’s all we need to load the content! The home page should now look like this:
An asset serving issue
With content loading working, we should look at one issue: the asset URLs. With the current setup, if you would inspect or open an image in a new tab, the URL would point to the image in Directus (e.g. http://localhost:8055/assets/bf59e22d-1d8b-4a15-bd52-6450213da5dc). During development that’s good, because it means we always see the latest (versions of) images in Directus. However, in a production deployment this would mean Directus becomes a production dependency, rather than just a build-time dependency. On top of that, this won’t work if the Directus endpoints are authenticated. All in all enough reason to improve this.
Bundling assets into Astro
To fix all issues mentioned above, we have to bundle the assets into the Astro site during build. To achieve this, we’ll set up a custom Astro integration, and apply some processing to the content. Let’s start with the integration.
Creating an Astro integration is very simple, all we have to do is create a function that returns an AstroIntegration, and add that to the configuration. We’ll set up our integration in lib/directus.ts:
import type { AstroIntegration } from 'astro';
import * as fs from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import { CMS_URL } from '../src/consts.ts';
import { createDirectus, readAssetRaw, readFiles, rest } from '@directus/sdk';
// Where the files should be downloaded.
const FILES_PATH = 'public/assets';
export function directusAssetBundler(): AstroIntegration {
return {
name: 'directus-asset-bundler',
hooks: {
'astro:build:start': async ({ logger }) => {
logger.info('Downloading assets');
// Ensure the target directory exists.
await fs.mkdir(FILES_PATH, { recursive: true });
const directus = createDirectus<{}>(CMS_URL).with(rest());
// List all files, go over each one.
const files = await directus.request(readFiles({ limit: -1 }));
for (const file of files) {
const local = path.resolve(FILES_PATH, file.id);
// If the file already exists, skip it.
if (existsSync(local)) {
continue;
}
// Download and store the file.
const asset = await directus.request(readAssetRaw(file.id));
await fs.writeFile(local, asset);
}
logger.info('✓ Completed');
},
},
};
}
lib/directus.ts
The code is fairly self-explanatory, but in short: we hook into the astro:build:start step of the build process (see the Astro docs for more information about these steps). During that, we connect to Directus, list all files, and download them to the public/assets directory. Note that this path is specifically chosen, and will become relevant in a moment. But first, add our new integration to the configuration:
import { defineConfig } from 'astro/config';
import { directusAssetBundler } from './lib/directus.ts';
export default defineConfig({
site: 'https://example.com',
integrations: [directusAssetBundler()],
// Other configuration...
});
astro.config.mjs
And that’s it! Now when you run the build using npm run build, you should see some log messages coming from the bundler:
This should also result in some files in the public directory.
Now about the public/assets path: public is where Astro serves static files from, like the favicon. The assets part comes from Directus, which serves images from /assets/:id. By using this same path for our local bundle, we keep the content preprocessing simple, which we should look at next.
First, let’s ensure our getImageUrl function in src/directus.ts points to our bundled files on production:
export function getImageUrl(id: string): string {
return process.env.NODE_ENV !== 'production'
? `${CMS_URL}/assets/${id}`
: `/assets/${id}`;
}
That’s simple enough, but we also need to look at images in our content, since that could also contain image URLs that point to Directus. The simplest fix for this is to simply remove all references to Directus from the content, replacing the base URL with empty strings. This will keep the rest of the path which points to the asset, which will be bundled in Astro:
async function processContent<T extends { content: string }>(
itemPromise: Promise<T>,
): Promise<T> {
const item = await itemPromise;
if (process.env.NODE_ENV === 'production') {
// Remove all Directus URLs, so the paths will point to the bundled assets.
item.content = item.content.replaceAll(CMS_URL, '');
}
return item;
}
Finally, we need to update our getHome function to use this processing:
export function getHome(): Promise<Home> {
return processContent(
directus.request(readSingleton('Home'))
);
}
Previewing
That’s it! You can preview the site using npm run preview, or run npm run build followed by npx serve dist to simulate a full build and statically serving it.
Conclusion and potential improvements
I like this setup quite a bit, and I’ve used it for various sites so far without any issues. It has become my go-to setup for new static-site projects. Since I’ve now done it a couple of times, setting it up is really only a matter of minutes.
There are - as always - certain things that could be improved upon. For example, the asset bundler integration downloads files one by one, which is probably much slower than a bulk download - especially as the amount of assets in a site grows. Apart from that, the preprocessing (removing the Directus URLs) feels just a tad hacky, but the reasoning is that you won’t really have a reference to Directus in your content anyway, and so far for me that’s been true. There are probably more points, which may or may not cause me problems later down the line, but so far it’s served me well! If I do run into anything, I’ll update this post or create a new one with my findings and solutions!