Using reference based linking from Contentful

Posted last year
Return to overview

First, let's list all the tags that we've added to the system. We're going to add a composable (again!) which grabs all of the existing tags and return them in with `title` and `slug`. You can give it a try yourself (take example from `usePagesNav` or `useArticles`) or you could just follow along.

We'll be making changes to the `composables/contentful.ts`. First, I've noticed that the interface `PageNav` is going to be exactly the same as a `TagNav` interface will be. In fact, all navigation purposed interfaces should have a `title` and a `slug`, so I did some rewiring on making use of the `extends` option of TypeScript.

I've added a new interface like this:

interface NavItem { title: string; slug: string; }

And applied it to the `PageNav` and a newly added `TagNav` like this:

interface PageNav extends NavItem {}; interface TagNav extends NavItem {};

This is telling TypeScript that both `PageNav` as well as `TagNav` have the same base: the `NavItem` interface. So good for reusability. If I wanted to add a specific property to either, I could just add it to that particular interface. Neat!

Refactoring time!

Since we're adding a bit more complexity to how we handle Articles, it's as good time as any to do some refactoring on this behalf.

First thing we can do is simplify the composable for getting a single Article. It's now combined with getting one or more, but at this point that just adds complexity in our codebase.

Add the following code as a new composable:

export const useArticle = async (options: { key: string; searchParams: object; }): Promise<Article> => { const query = options.searchParams; const { data } = await useAsyncData(`article-${options?.key}`, async (nuxtApp) => { const { $contentfulClient } = nuxtApp return $contentfulClient.getEntries({ content_type: 'post', ...query, }) }) const { title, body, heroImage } = data?.value?.items[0]?.fields; return { title, body, heroImage } }

We've stripped the decision making between grabbing one or multiple articles. We've also added a bit of abstraction in the query that's responsible for identifying the correct article. We're going to add it (slug based search) using another composable for abstraction of the `searchParams`. Add the following composable:

export const useSlugQuery = (slug: string): object => ({ 'fields.slug': slug, limit: 1});

This does nothing more than handle how Contentful receives the slug.

We're going to combine the two on the `ArticleBody.vue` component by replacing the `useArticles` composable. The contents of the `script` tag should look like this:

<script lang="ts" setup> interface Props { slug: string; } const props = defineProps<Props>(); const searchParams = useSlugQuery(props.slug); const { title, body } = await useArticle({ searchParams, key: props.slug }); </script>

If all goes well, the page referring to a specific Article should still work now as it did. We just decoupled it from the `useArticles` composable.

Let's start with adding and showing the Tags on the `ArticleOverview` page. For this, you guessed it, a new composable:

export const useTags = async (): Promise<TagNav[]> => { const { data } = await useAsyncData('tagNav', async (nuxtApp) => { const { $contentfulClient } = nuxtApp return $contentfulClient.getEntries({ content_type: 'tag' }) }) const tagsNav = data?.value?.items .map((tagFields: any) => { const { title, slug } = tagFields.fields return { title, slug } }) return tagsNav }

As you can see, we're using the defined TagNav as the interface for the return value. Other than that, this should all look very familiar. We can start to implement this on the Articles page. At this point you should be able to add the composable line to the script tags:

<script lang="ts" setup> const articles = await useArticles(); const tags = await useTags(); </script>

And modify the template like this (we're just adding a new set of links, pointing to a specific path `/articles/with/${tag}`):

<template> <h1>Articles</h1> <nav> <a v-for="item in articles" :key="item.slug" :href="`/articles/${item.slug}`" :title="item.title" >{{ item.title }} </a> </nav> <h2>Tags</h2> <nav> <a v-for="item in tags" :key="item.slug" :href="`/articles/with/${item.slug}`" :title="item.title" >{{ item.title }} </a> </nav> </template>

If you refresh the page, you see a collection of the Tags you added via Contentful. But it now links to a non existing route, so we should fix that. Again, we make use of the file based routing system that's built into Nuxt. Create a new folder called `/with` to `pages/articles/` and add a page called `[slug].vue` in the folder. This routes everything on `/articles/with/SLUG` to the corresponding Vue page while passing down the `SLUG` as a route parameter.

We need some composing before we can make this page work, so let's get cracking again.

If we want to display Articles based on references tags, we can make use of the `links_to_entry` option on the Contentful API. And for that, we first need to resolve the Id of the Tag, because in the URL we only have a slug at our disposal.

This composable does a lookup in the Tags and returns the `links_to_entry` value (we're reusing the `useSlugQuery` composable):

export const useTagSearch = async (slug: string): Promise<ContentfulSearch> => { const query = useSlugQuery(slug); const { data } = await useAsyncData(`tagSearch-${slug}`, async (nuxtApp) => { const { $contentfulClient } = nuxtApp return $contentfulClient.getEntries({ content_type: 'tag', ...query }) }) if (data?.value?.items?.length === 1) { return { links_to_entry: data?.value.items[0].sys.id }; } return { links_to_entry: '__NO__ID__FOUND__' } }

And with the result of this composable, we can enrich the `useArticles` composable to take this into the query. We only pass it down when we're on the `pages/articles/with` path, for the `<ArticleOverview />` component, so you can paste these contents in the `[slug].vue` file:

<script lang="ts"> definePageMeta({ layout: "default", }); </script> <template> <ArticleOverview :tag="$route.params.slug" /> </template>

We've now added a prop to the `<ArticleOverview />` component, so we should make sure to read from the optional prop and we're also going to use the prop to determine relevant searchParams:

<script lang="ts" setup> interface Props { tag?: string; } const props = defineProps<Props>(); const searchParams = props.tag ? await useTagSearch(props.tag) : undefined; const articles = await useArticles(); const tags = await useTags(); </script>

The last thing we need to do is to refactor the `useArticles` composable. We need to strip out the retrieval of a single Article (we now have a separate composable for that) and add the search by tag:

export const useArticles = async (options: { key: string; searchParams?: object; }): Promise<Article[]> => { let query: object; if (options.searchParams) query = options.searchParams; const { data } = await useAsyncData(`articles-${options.key}`, async (nuxtApp) => { const { $contentfulClient } = nuxtApp return $contentfulClient.getEntries({ content_type: 'post', ...query, }) }) return data.value.items.map((item: any) => { const { title, slug } = item.fields; return { title, slug } }) }

So, here we did some cleaning up and made sure we can pass down search parameters with a unique key per search (Nuxt caches the result, remember?)

Finally we should update the `<ArticleOverview />` component to pass any query into the composable.

That's just a matter of replacing a line, since we have everything in place already:

const articles = await useArticles({ searchParams, key: props?.tag || '' });

Try it out! This should now allow you to browse Articles via the overview page (showing all) or navigating via the Tags overview.

At this point the code should resemble this stage. We're already making good progress! We're going to do a couple of finishing touches, but basically (apart from how good it looks), this is a functional already.

Most of the heavy lifting in the application is done now, so I hope you have a decent understanding on the capabilities of Nuxt as a static website/blog engine, lightweight TypeScript introduction and connecting to a headless CMS.

At this point, I hope you've also experienced that you can always consider small refactoring steps as you code. There's some abstraction involved for reusable bits. Personally, for these type of projects the abstraction level should still make sense and be readable after not touch the code for half a year. So refrain from over engineering mundane features.

Feel free to modify the code, there's some improvements you could make at this point, like:

  • Showing only Tags that are being used

  • Showing the Tags per Article and make them clickable

  • Toggle which Pages are visible in the Main menu

  • Create a menu for a subset of Pages

Good luck and I'll see you in the next step.

Return to overview