Tags and Search Functionality in Nuxt Using Storyblok API

I want you to think for a moment: when you get into the role of a blog reader, what do you really want?

In most cases, you probably want to easily find content relevant and interesting to you.

You might be interested in apples, and I am only interested in pineapples. Maybe you're just interested in sweet fruit, or you want to find only the purple fruit.

What you're looking to do is to narrow down the content and navigate through it. We can do this in the following two ways:

  • Categorization: use of tags, topics, taxonomies or any type of category.
  • Search: unstructured way to find content more easily

Let's see how to implement this in our blog.

# Content Topics in the Nuxt Blog

In the article Setting up the blog content structure in Storyblok we've already added some tags to the articles we've created.

At this point I encourage you to create some more articles so you'll better understand this section.

The categorization strategy we're going to implement is simple: an article can belong to different topics and we want to have a page for each topic, listing all the articles related to that topic.

You don't need to do anything in Storyblok. As you've seen before, we'll be using tags for it, and the rest of the functionality is there for us.

Let's start by creating the routing for the topics pages.

They will have a url following the form of /topics/:slug so we have to create the page at pages/topics/_slug.vue. It'll have a similar shape to the home page, so for now copy/paste the template from pages/index.vue:

<template>
  <div class="mt-4">
    <section>
      <div class="container mx-auto px-4">
        <h1 class="text-4xl font-bold">Articles</h1>
        <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 mt-4">
          <ArticleCard
            v-for="article in articles"
            :key="article.content.title"
            :slug="`/${article.slug}`"
            :title="article.content.title"
            :description="article.content.description"
            :author="article.content.author"
            :date="article.content.date.toLocaleDateString()"
          />
        </div>
      </div>
    </section>
  </div>
</template>

We'll link to the topic page from the article detail, located at pages/_slug.vue. A story in Storyblok includes a tag_list field:

{
	id: 17434028,
        // ...
	tag_list: ['Beast', 'Hokage']
}

Let's use it in order to show the tags just below the <header> tag in pages/_slug.vue:

<header>...</header>
<div class="mt-4">
  <nuxt-link
    v-for="tag in article.tag_list"
    :key="tag"
    :to="`/topics/${tagSlug(tag)}`"
    class="rounded-full text-white bg-main uppercase text-sm mr-2 px-2 py-1"
    >{{ tag }}</nuxt-link>
</div>
<div v-html="$md.render(article.content.content)" class="prose mt-8"></div>

Since the tags can have any string shape, you have to make sure it gets converted to a slug so it can be a valid link. That's why you see tagSlug(tag) in there.

You can implement it with lodash easily:

import kebabCase from 'lodash/kebabCase'

export default {
  // ...
  methods: {
    tagSlug(tag) {
      return kebabCase(tag)
    },
  }
}

The result should look like this:

https://a.storyblok.com/f/83078/1558x822/f69586ee71/05-01-tags.png

Note: Don't worry about the search box, I'll get into that later 😉.

Going back to pages/topics/slug.vue, we have the opposite problem here: we have access to the **topic slug, but not to the topic itself.

What we can do is to fetch the topics using the tags resource and find the tag from the response.

That call returns an object with the following shape:

{
  "tags": [
    { "name": "Beasts", "taggings_count": 2 },
    { "name": "Techniques", "taggings_count": 1 }
  ]
}

Let's use the asyncData function on pages/topics/slug.vue to perform the call and search for the right topic name*:*

import kebabCase from 'lodash/kebabCase'

export default {
  async asyncData({ app, params }) {
    // Find tag based on the slug
    const { data: tagsData } = await app.$storyapi.get('cdn/tags/')
    const topic = tagsData.tags.find((t) => kebabCase(t.name) === params.slug)
  }
}

Notice that we're calling cdn/tags/ and the kebabCase operation to compare the slugs. Using params.slug you're accessing the slug from the route url parameter.

Now that we have the right topic, let's retrieve the articles for that topic. You need to use the with_tag stories option:

async asyncData({ app, params }) {
  // Find tag based on the slug
  const { data: tagsData } = await app.$storyapi.get('cdn/tags/')
  const topic = tagsData.tags.find((t) => kebabCase(t.name) === params.slug)

  // Fetch articles
  const { data: articlesData } = await app.$storyapi.get('cdn/stories', {
    starts_with: 'articles/',
    resolve_relations: 'author',
    with_tag: topic.name,
  })
  const articles = articlesData.stories.map((story) => {
    story.content.date = new Date(story.content.date)
    return story
  })

  return { topic, articles }
},

Finally, to differentiate it from the home page, update the page <h1> tag to show as well the topic name and count:

<h1 class="text-4xl font-bold">
  {{ topic.taggings_count }} articles on <i>#{{ topic.name }}</i>
</h1>

The page will look similar to this:

https://a.storyblok.com/f/83078/1692x910/f85bef9975/05-02-topics-page.png

# Search Content Using Storyblok API

Having a way to navigate through different topics is great, but sometimes we want to search for something specific more easily.

That's where search comes into place, and you will rarely see a decent blog or content-based website without a search box.

Let's start by creating a SearchBox component. It must have a search input, and when the user types in it, it will perform a call to get the stories that match the criteria.

An example of that SearchBox could be:

<template>
  <div class="relative">
		<!-- Magnifying glass icon -->
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 512 512"
      class="block absolute text-gray-600 right-0 z-10 h-4 fill-current mr-3"
      style="top: 9px;"
    >
      <path
        d="M508.5 468.9L387.1 347.5c-2.3-2.3-5.3-3.5-8.5-3.5h-13.2c31.5-36.5 50.6-84 50.6-136C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c52 0 99.5-19.1 136-50.6v13.2c0 3.2 1.3 6.2 3.5 8.5l121.4 121.4c4.7 4.7 12.3 4.7 17 0l22.6-22.6c4.7-4.7 4.7-12.3 0-17zM208 368c-88.4 0-160-71.6-160-160S119.6 48 208 48s160 71.6 160 160-71.6 160-160 160z"
      ></path>
    </svg>
    <!-- Search Input -->
    <input
      v-model="searchInput"
      @input="onInputChange"
      @blur="onInputBlur"
      placeholder="Search..."
      class="w-full bg-white text-gray-700 rounded border-2 border-transparent outline-none focus:border-purple-500 px-4 py-1"
    />
    <!-- Suggestions list -->
    <div class="relative">
      <div
        class="absolute z-30 bg-white top-0 inset-x-0 rounded shadow-lg mt-1"
      >
        <nuxt-link
          v-for="suggestion in suggestions"
          :key="suggestion.id"
          :to="`/${suggestion.slug}`"
          class="block truncate text-gray-700 hover:text-main hover:bg-gray-100 px-4 py-2"
        >
          {{ suggestion.content.title }}
        </nuxt-link>
      </div>
    </div>
  </div>
</template>

<script>
import debounce from 'lodash/debounce'

export default {
  props: {
    search: {
      type: Function,
      required: true,
    },
  },
  data: () => ({
    searchInput: '',
    suggestions: [],
  }),
  methods: {
    onInputChange: debounce(async function () {
      this.suggestions = await this.search(this.searchInput)
    }, 300),
    onInputBlur() {
      setTimeout(() => (this.suggestions = []), 300)
    },
  },
}
</script>

I won't explain this component in detail, since it's not relevant. You're free to code your own or to use any existing libraries like the finely done vue-multiselect.

What's important on this SearchBox component is:

  • It keeps a searchInput and suggestions state.
  • When the search input changes, it performs a call to retrieve the results. We do this by listening to the @input event and delegating the responsibility for how the call must be done to the parent component by receiving a search property. It's convenient as well to debounce the function, so it doesn't perform a call for every keystroke, but instead it'll do that when the user stops typing for 300ms.
  • When the user blurs the input, we empty the suggestions to close the suggestion list. But we do it 300ms later, otherwise it won't be possible to click on a suggestion since the list closes immediately on blur.

Now you can use it on components/layout/AppHeader.vue to show the SearchBox in the header. But before you do that, let me explain something.

When we talk about a full-text search, it means that given a registry of data (in this case the article stories) the system performs a search on all of the fields you need. Usually, this can be quite complex, and you have technologies specifically built for it (like ElasticSearch) and services (like Algolia).

Here comes one of my favorite parts of Storyblok: it gives you a free full-text search, and it's actually quite easy to use.

All you need to do is to use the search_term parameter on your API query. Use it on your AppHeader.vue component and make use of the SearchBox:

<template>
  <header class="w-full bg-main py-2">
    <div class="container mx-auto px-4">
      <div class="flex">
        <nuxt-link to="/" class="text-2xl text-white font-semibold">
          NarutoDose
        </nuxt-link>
        <SearchBox :search="fetchSuggestions" class="flex-1 max-w-sm ml-12" />
      </div>
    </div>
  </header>
</template>

<script>
export default {
  methods: {
    async fetchSuggestions(searchInput) {
      const { data } = await this.$storyapi.get('cdn/stories', {
        starts_with: 'articles/',
        resolve_relations: 'author',
        search_term: searchInput,
        per_page: 5,
      })

      return data.stories
    },
  },
}
</script>

I've also added a per_page so we don't have a bunch of results, but the 5 most relevant.

When you run it, you should see it working like this:

https://a.storyblok.com/f/83078/1634x910/feeb466f9a/05-03-search.png

# To Sum Up

Making it easy for your readers to find the content that they want doesn't need to be complicated nor complex. Providing them with categorization, tagging and search functionality is a good way to achieve it.

You've seen how easy it is to do that in a Nuxt blog thanks to Storyblok and all the great capabilities its API has.

But the job is not done...

Is the blog discoverable by the search engines yet?

Probably we still have a lot to do about SEO... stay tuned and we'll solve that next week in part 6!

Do you want to play with the code? Find it on Github.

Don't miss out anything about Vue, your favourite framework.

Subscribe to receive all the articles we publish in a concise format, perfect for busy devs.

Related Articles

The new Provide and Inject in Vue 3

Getting stuck into the prop drilling? Learn how provide/inject can make your components more flexible and independent in this short tutorial.

Anthony Konstantinidis

Anthony Konstantinidis

Jul 18, 2022

The 101 guide to Script Setup in Vue 3

Don't you know about Script Setup yet? Check out this short article now and learn the nicest way to define a Vue component right now!

Anthony Konstantinidis

Anthony Konstantinidis

Jun 20, 2022

Hybrid Rendering: the secret way to smoothly test Vue.js components

Find out how to combine Deep and Shallow Rendering in order to achieve a flexible solution to test your Vue.js combining the best of both worlds.

Alex Jover Morales

Alex Jover Morales

Mar 7, 2022

Sponsors

VueDose is proudly supported by its sponsors. If you enjoy it, consider supporting it to ensure the project maintainability.

Silver
Learning Partner