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:
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:
# 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
andsuggestions
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 asearch
property. It's convenient as well todebounce
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:
# 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.
How we built our blog as a full-static site with Nuxt, Storyblok and TailwindCSS
This article is part of an amazing guide.
- Setting Up a Full Static Nuxt Site
- Creating UI Components based on a Design System in Vue.js
- Setting up the blog content structure in Storyblok
- Show the Blog Content in Nuxt Using Storyblok API
- Tags and Search Functionality in Nuxt Using Storyblok API
- Optimize SEO and Social Media Sharing in a Nuxt blog
- Generate and deploy the blog as a full static Nuxt site
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.
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!
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.
Mar 7, 2022
Sponsors
VueDose is proudly supported by its sponsors. If you enjoy it, consider supporting it to ensure the project maintainability.