Now that you know why you should use Arboretum and how to configure your Contentful environment to use it, it’s time to finally show your content. So we need a frontend for that.
Arboretum is agnostic of your frontend solution of choice. If it can run JavaScript code, that’s great because you can just use the Arboretum SDK then. In rare situations when that’s not possible, you’ll need to recreate what Arboretum SDK does yourself.
In this example, we’re going to use Next.js with React but if you squint hard enough, you should be able to adjust the snippets to your use case - whether it’s Astro, Solid, Remix, Nuxt, or anything even remotely similar.
The goal is to have a frontend that renders any sitemap tree modeled in Contentful. Ideally, we should not be hardcoding anything about this structure. Will we succeed and is it even possible? Let’s find out.
Arboretum SDK
At this point, you should have installed and configured the Arboretum App for your Contentful environment. If that’s not the case, refer to this article for a detailed guide.
You also need some Next.js project. We recommend creating an empty one to make it easier to start but if you’re feeling brave, you definitely can also use an existing one. The examples will use npm
and TypeScript but pick anything you like in this field.
Arboretum SDK is a lightweight but powerful, zero-dependency library written in TypeScript to interact with Contentful APIs. It can run both in a server-environment like Node.js and in the browser which makes it a great solution for a wide variety of use cases.
Add Arboretum SDK to your dependencies with:
npm install @bright.global/arboretum-sdk
After the installation is completed, it’s time to set up the Arboretum client. It needs to be constructed with parameters you configured your Arboretum App with and your Contentful credentials so that Arboretum can query it.
There are different ways to create a client but the easiest one is to copy the configuration object from the configuration screen of the Arboretum App in Contentful (see below).
You can find the configuration screen by navigating to Apps > Installed apps > Arboretum > Configure.
You’ll only need to fill in the access token yourself as it cannot be read by the Arboretum App directly.
You can configure the client for either preview or published modes by setting the preview parameter in the configuration object. When in preview mode, the Arboretum uses the Content Preview API (CPA for short) and in the published mode, Arboretum uses the most performant Content Delivery API (CDA). Make sure the token you entered can access the environment you use and that CDA access token is used when preview is false and vice-versa.
Create an empty file in the location of your choice and construct the client. It should look like this:
const config = {
"type": "cda-client-params",
"preview": false,
"contentful": {
"space": "<YOUR_SPACE_ID>",
"environment": "<YOUR_ENV>",
"accessToken": "<CDA_ACCESS_TOKEN_HERE>",
"options": {
"pageContentTypes": {
"page": {
"slugFieldId": "slug",
"titleFieldId": "slug",
"childPagesFieldId": "contentArea"
}
}
}
}
}
const { client, warnings } = await createArboretumClient(config);
You might have noticed that creating a client is asynchronous. It may happen you cannot use or don’t want to use the top level await. In such cases, you can adjust it to fit your requirements. It might be something like this:
export function arboretumClientFactory() {
return createArboretumClient(config);
}
Arboretum Initialization
Arboretum SDK lifecycle is split into two parts - creating a client and using it. When an Arboretum instance is created, it fetches your Contentful environment for all the data it will ever need - all defined locales, content types, all page entries defined in the configuration.
Queries are done in parallel and, if your site isn’t too large, this can be done with just 4 simple requests. If your site is bigger, it may happen that the request fetching page entries needs to span over multiple requests (because of 1000 entries per request Contentful limit). In reality, it’s not a huge deal as this process only needs to happen once for the client's object life.
Once the data is fetched, Arboretum creates an internal data structure that maps out your website’s page hierarchy, allowing it to efficiently interact with a sitemap without any additional API calls to Contentful. Yes, you read that right. After the client is constructed, all the queries to the Arboretum are fast and synchronous.
Updating Arboretum state
The client includes a method to refetch the latest content from Contentful and update its internal state. You can do it like this:
const { status, warnings } = await client.regenerate();
Content-driven sitemap
Just to recap - your pages content model forms a tree. At the root of the tree is a page selected to be what in Web-lingo is known as a homepage. Then, this page becomes a parent for all the child pages via references. Each of the child pages can also have their own children and so the process continues deeper and deeper. Even though the level of nesting is not limited, in practice these structures are usually only a few levels deep.
Another dimension that plays an important role in sitemap creation is localisation. We’re not going to unbox this pandora box here so as not to lose focus. For brevity, let’s use a simple case of having just a single English locale.
Using Next.js to handle site with a content-driven sitemap
We’re going to use Next.js "catch-all" feature to handle all possible pages in a single place. It enables us not to hardcode anything about the sitemap structure and off-load this task to the Arboretum. That’s exactly what we need.
Create a file at src/app/[[...slugs]]/page.tsx
with the following content:
type Props = { params: Promise<{ slugs: Array<string> | undefined }> }
export default async function Page(props: Props) {
const { slugs } = await props.params;
return null;
}
We’re using Next.js 15 where params are async (hence the Promise wrapper). If you’re still on v14 or lower, you should remove it.
Exploring the Arboretum SDK, we discover the pageByPath
method. It takes the page's path as an argument and returns the page id result.
We need to construct an Arboretum page full path. For our single English locale, we can do it like this:
function getPagePath(slugs: Array<string> | undefined) {
let ret = "/en";
if (!slugs) {
return ret;
}
return ret + "/" + slugs.join("/");
}
Combining all of that together, we have:
// src/app/[[...slugs]]/page.tsx
import { arboretumClient } from "@/arboretum";
import { notFound } from "next/navigation";
type Props = { params: Promise<{ slugs: Array<string> | undefined }> };
export default async function Page(props: Props) {
const { slugs } = await props.params;
const path = getPagePath(slugs);
const page = arboretumClient.pageByPath(path);
if (page._tag !== "Right") {
notFound();
}
return <span>Page id: {page.right.id}</span>;
}
function getPagePath(slugs: Array<string> | undefined) {
let ret = "/en";
if (!slugs) {
return ret;
}
return ret + "/" + slugs.join("/");
}
Notice how we handle the “page” variable. In case there’s no page for a given path, Arboretum returns it as an error. For a successful case, we get a page id that we can use to query Contentful for this page's content. How you do it is completely up to you so we’re going to omit this part here.
Final thoughts
Thanks to the Arboretum SDK, with just a few lines of code, we’ve been able to use a content-driven sitemap from Contentful with Next.js.
Contentful and Next.js make an excellent pairing for both server and serverless architectures. The only missing element in this setup is an efficient way to translate Next.js request paths to page entries from Contentful. With the Arboretum, the picture is complete. Together, they form a powerful combination, enabling the creation of fast, fully dynamic websites.
Bonus: Performance Considerations and Next.js Hosting
We’ve explored how to integrate the Arboretum client with Next.js to create a dynamically structured website, as well as how to implement parallel routes for preview mode. However, one key topic remains unaddressed: performance and caching. Since the Arboretum client fetches data from Contentful during initialization, how can we avoid unnecessary reinitializations?
Developers with some experience in Next.js might consider using the unstable_cache
feature from the next/cache
package to cache the Arboretum client. Unfortunately, there are two main issues with this approach. First, the cache only supports JSON-serializable objects, which the Arboretum client is not. Secondly, there are size limitations for such objects, and the representation of an entire sitemap can be quite large. While this could be a potential path for future Arboretum versions, it is not feasible at the moment.
Fortunately, there are some workarounds to address this caching challenge. In a basic single-server setup, we can leverage a global in-memory state that persists as long as the application is running. By storing the Arboretum client in memory and using simple time-based revalidation, we can significantly improve the application’s performance.
On Vercel, the situation is more complex due to the limited lifetime of serverless functions. When a serverless function terminates, the Arboretum client instance is lost. However, as long as the Vercel function (the computation engine behind serverless Node.js used by Vercel) remains "warm" it can reuse the same Arboretum client instance. Vercel ensures that at least one function instance stays warm on paid plans, which improves startup times for apps with moderate traffic. There is, however, one caveat - if more than one Vercel function is spawned to handle incoming requests, you may experience inconsistencies in the cached sitemap, as they will be created and refreshed at different times across function instances.
If your use case allows (e.g., you are on a single server or minor sitemap differences are not a major concern), here’s an example of how to maintain a global in-memory state to track initialized Arboretum clients (this approach is used in our sample Arboretum project repository arboretum-nextjs-example):
// code fragment taken from src/lib/arboretum.ts from BrightIT/arboretum-nextjs-example
type GlobalArboretum = {
client: ArboretumClientT;
lastRegeneration: Date;
regenerationInProgress: boolean;
};
declare const globalThis: {
publishedArboretum: GlobalArboretum;
previewArboretum: GlobalArboretum;
} & typeof global;
...
const arboretum = async (mode: Mode) => {
const instance: ArboretumInstance =
mode === "preview" ? "previewArboretum" : "publishedArboretum";
if (globalThis?.[instance]?.client) {
if (arboretumShouldRegenerate(mode)) {
console.log(`Arboretum ${mode} regeneration started`);
globalThis[instance].regenerationInProgress = true;
// fire and forget
globalThis[instance].client
.regenerate()
.then(({ warnings }) => {
console.log(`Arboretum ${mode} regeneration finished`);
if (warnings) {
console.warn(`Arboretum ${mode} warnings:`);
console.warn(warnings);
}
globalThis[instance] = {
...globalThis[instance],
lastRegeneration: new Date(),
};
})
.catch((err) => {
console.log(`Arboretum ${mode} regeneration failed`);
console.error(err);
})
.finally(() => {
globalThis[instance].regenerationInProgress = false;
});
}
return Promise.resolve(globalThis[instance].client);
} else {
const { client, warnings } = await arboretumClientSingleton(mode);
if (warnings) {
console.warn(`Arboretum ${mode} warnings:`);
console.warn(warnings);
}
globalThis[instance] = {
client: client,
lastRegeneration: new Date(),
regenerationInProgress: false,
};
return client;
}
};
With this helper function in your codebase, rather than explicitly creating a new Arboretum client each time, as we demonstrated earlier, you can simply invoke the arboretum function, which will reuse a cached, in-memory client. An added advantage of this helper function is that it automatically handles refreshing the Arboretum state - and by extension, your sitemap - after a specified interval, following the stale-while-revalidate strategy.