Build a Strapi blog with Nuxt 3
I was recently searching for a new Jamstack to move some clients’ blogs to and I came across Strapi and Nuxt.
Strapi is a popular headless, open-source CMS. Using Strapi helps you get a jump start on development, taking care of creating an API and admin portal for you. I also needed a static site generator for the front-end and I decided on Nuxt. Nuxt is a powerful framework built on Vue.
Once I picked my stack, I went looking for a quick tutorial to get myself up to speed. Unfortunately… I ran into a common problem that web developers run into. Many tutorials were out of date. It’s inevitable in web development as projects are always rapidly improving.
I decided I’d write my own tutorial and hopefully help some other developers get started in Strapi and Nuxt …at least until this tutorial meets its outdated end.
Lets begin!
Install The Strapi Blog Template
This part is easy. We will use Strapi’s own blog template!
I like to create small projects like this in a monorepo, which means we will have a single root folder with subfolders for each part of the project. We will go ahead and create a new folder nuxt-strapi-blog.
Now open up a terminal in the nuxt-strapi-blog folder and run:
npx create-strapi-app backend --template blog --quickstart --no-run
This will create our Strapi blog in the back-end folder. Now we can move into the back-end folder and install a couple additional packages:
cd backend
npx strapi install graphql
npm install strapi-plugin-populate-deep
I highly recommend these two plugins.
The first is the Strapi GraphQL plugin. It will allow you to navigate to http://localhost:1337/graphql and poke around in the GraphQL Playground. The playground will allow you to test out some queries and see what Strapi will return to us. This can be extremely helpful to any developer that is new to Strapi or GraphQL.
The second plugin, strapi-plugin-populate-deep, is a plugin I refuse to live without. In Strapi 4, queries will only return 2 levels of data deep. If you have nested objects, complex relations, or objects with media, normally we would be required to manually populate the nested objects, a task that gets complex fast. Strapi-plugin-populate-deep will automatically have Strapi populate those nested objects for us.
Start the server:
npm run develop
Navigate to http://localhost:1337 and you will be greeted with the “Welcome to Strapi” screen.

Here you can enter your desired admin information. You can explore the Strapi admin page and when you’re ready we can move onto building our front-end in Nuxt.
Installing Nuxt
Make sure you’re in the nuxt-strapi-blog folder and not the backend folder. Run the following to create the frontend of our application.
npx nuxi init frontend
cd frontend
We will also install a few additional packages. We will be using Tailwind CSS for styling, NuxtStrapi to help connect to Strapi, and vue3-carousel to display a nice sliding image gallery.
npm install postcss tailwindcss @tailwindcss/typography @nuxtjs/strapi markdown-it postcss-loader vue3-carousel @types/markdown-it
npm install
npm run dev
Now if we open up http://localhost:3000/ we should see the “Welcome To Nuxt” screen. With Nuxt installed and we are ready to start crafting the front-end.
Configuration
We will add some configuration code to our app.
Create a new file called tailwind.config.js in the front end folder and add the following code.
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./components/**/*.{js,vue,ts}",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./plugins/**/*.{js,ts}",
"./nuxt.config.{js,ts}",
"./app.vue",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
],
}
There should now be a complaint about a missing CSS file when we try to build. Lets add main.css inside frontend/assets/css and inside the file 3 imports:
@tailwind base;
@tailwind components;
@tailwind utilities;
We will also need to add a nuxt.config.ts in the root of our project. This file holds the configuration for files imported to Nuxt. Most importantly, it enables the Nuxt Strapi connector and Tailwind CSS .
// https://nuxt.com/docs/api/configuration/nuxt-config
const strapiBaseUri = process.env.API_URL || "http://127.0.0.1:1337";
export default defineNuxtConfig({
modules: [
'@nuxtjs/strapi',
],
strapi: {
url: strapiBaseUri,
prefix: '/api',
version: 'v4',
cookie: {},
cookieName: 'strapi_jwt',
},
css: ['~/assets/css/main.css'],
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
})
Lets start adding code to the Nuxt app.
Inside app.vue:
<template>
<NuxtLayout>
<NuxtPage
:global="global"
/>
</NuxtLayout>
</template>
<script setup>
const { find } = useStrapi4();
const globalResponse = await find('global', {
populate: 'deep',
});
let global = globalResponse.data.attributes;
// add some default SEO realted tags
useHead({
title: global?.defaultSeo.metaTitle,
htmlAttrs: { lang: 'en' },
link: [
{ rel: 'icon', type: 'image/png', href: getStrapiMedia(global?.favicon?.data?.attributes?.url) },
],
meta: [
{ name: 'description', content: global?.defaultSeo?.metaDescription },
{ name: 'og:title', content: global?.defaultSeo?.metaTitle },
{ name: 'og:description', content: global?.defaultSeo?.metaDescription },
{ name: 'og:type', content: 'website' },
{ name: 'og:locale', content: 'en_US' },
{ name: 'og:image', content: getStrapiMedia(global?.defaultSeo?.shareImage?.data.attributes.url) },
],
})
</script>
Create a new folder frontend/utils and create media.js:
export function getStrapiMedia(url) {
const config = useRuntimeConfig()
if (url.startsWith("/")) {
return config.strapiBaseUri ? `${config.strapiBaseUri}${url}` : `http://127.0.0.1:1337${url}`;
}
return config.strapiBaseUri ? `${config.strapiBaseUri}/${url}` : `http://127.0.0.1:1337/${url}`;;
}
Now we will create our default layout. In frontend/layouts create default.vue:
<template>
<div>
<div class="flex flex-col justify-between bg-neutral-50 text-neutral-900">
<Navbar />
</div>
<slot />
</div>
</template>
In frontend/components create Navbar.vue:
<template>
<div class="bg-primary-200">
<div class="flex flex-row items-baseline justify-between py-6 px-4">
<NuxtLink to="/" class="text-xl font-medium">
<strong>{{ global.siteName }}</strong>
</NuxtLink>
<nav class="flex flex-row items-baseline justify-end">
<NuxtLink class="font-medium px-2" to="/about">About Us</NuxtLink>
<NuxtLink
v-for="category in categories"
:to="{ name: 'categories-slug', params: { slug: category.attributes.slug } }"
class="font-medium px-2"
>{{ category.attributes.name }}</NuxtLink>
</nav>
</div>
</div>
</template>
<script setup>
const { find } = useStrapi4();
const globalResponse = await find('global', {
populate: 'deep',
});
let global = globalResponse.data.attributes;
const categoriesResponse = await find('categories', {
populate: 'deep',
});
let categories = categoriesResponse.data
</script>
Lets create the rest of our components in the same folder:
ArticleCard.vue
<template>
<nuxt-link
:key="article.id"
:to="{ name: 'articles-slug', params: { slug: article.attributes.slug } }"
class="overflow-hidden rounded-lg bg-white shadow-sm transition-shadow hover:shadow-md"
>
<img :src="getStrapiMedia(article.attributes.cover.data.attributes.url)" height="100" />
<div class="px-4 py-4">
<p v-if="article.attributes.category" class="font-bold text-neutral-700 uppercase">
{{ article.attributes.category.data.attributes.name }}
</p>
<div >
<h2 id="title" class="text-xl font-bold text-neutral-700">{{ article.attributes.title }}</h2>
<p class="line-clamp-2 mt-2 text-neutral-500">
{{ article.attributes.description }}
</p>
</div>
<div class="flex mt-2">
<img :src="getStrapiMedia(article.attributes.author.data.attributes.avatar.data.attributes.url)"
style="border-radius: 50%; object-fit: cover" width="40" height="40" :alt="article.attributes.title" />
<span class="px-2 self-center text-neutral-700">{{ article.attributes.author.data.attributes.name }}</span>
</div>
</div>
</nuxt-link>
</template>
<script>
import { getStrapiMedia } from '../utils/media';
export default {
props: {
article: {
type: Object,
default: () => ({}),
},
},
methods: {
getStrapiMedia,
},
};
</script>
Articles.vue:
<template>
<div class="container mt-12 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 mx-auto">
<ArticleCard
v-for="article in articles"
:key="article.id"
:article="article"
/>
</div>
</template>
<script>
import ArticleCard from "./ArticleCard";
export default {
components: {
ArticleCard,
},
props: {
articles: {
type: Array,
default: () => [],
},
},
};
</script>
Blocks.vue:
This component will intelligently display dynamic zones received from Strapi.
<template>
<div v-for="block in blocks">
<div
v-if="block.__component === 'shared.rich-text'"
class="prose max-w-4xl mx-auto py-8 px-3"
>
<div
v-html="mdRenderer.render(block.body)"
/>
</div>
<div
v-else-if="block.__component === 'shared.media'"
class="py-8"
>
<img :src="getStrapiMedia(block.file.data.attributes.url)" class="w-screen"/>
</div>
<div v-else-if="block.__component === 'shared.quote'" class="px-3 py-6">
<blockquote class="container max-w-xl border-l-4 border-neutral-700 py-2 pl-6 text-neutral-700">
<p class="text-5xl font-medium italic">{{block.body}}</p>
<cite class="mt-4 block font-bold uppercase not-italic">
{{block.title}}
</cite>
</blockquote>
</div>
<div v-else-if="block.__component === 'shared.slider'">
<MediaSlider
:files="block.files.data"
/>
</div>
<div v-else>{{block}}</div>
</div>
</template>
<script setup>
import md from "markdown-it";
const mdRenderer = md();
const props = defineProps({
blocks: {
type: Array,
required: true,
},
});
</script>
Heading.vue:
<template>
<div class="px-4 py-4">
<h1 class="text-6xl font-bold text-neutral-700">{{ title }}</h1>
<p v-if="description" class="mt-4 text-2xl text-neutral-500">{{ description }}</p>
</div>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: false,
},
});
</script>
MediaSlider.vue:
This component will display media galleries from Strapi.
<template>
<div>
<Carousel :items-to-show="1.5">
<Slide v-for="file in files" :key="file.id">
<img :src="getStrapiMedia(file.attributes.url)" />
</Slide>
<template #addons>
<Navigation />
<Pagination />
</template>
</Carousel>
</div>
</template>
<script setup>
import 'vue3-carousel/dist/carousel.css'
import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel'
const props = defineProps({
files: {
type: Array,
required: true,
},
});
</script>
Now it is time to move to the frontend/pages folder and create some pages. Lets start with our static pages.
index.vue:
<template>
<div>
<Heading
:title="props.global.siteName"
:description="props.global.siteDescription"
/>
<main>
<Articles :articles="articles" />
</main>
</div>
</template>
<script setup>
const props = defineProps({
global: Object
})
const { find } = useStrapi4();
const articlesResponse = await find('articles', {
populate: 'deep',
});
let articles = articlesResponse.data;
</script>
<style>
body {
margin: 0;
}
</style>
about.vue
<template>
<div>
<Heading
:title="about.title"
:description="about.description"
/>
<Blocks
v-if="about.blocks"
:blocks="about.blocks"
/>
</div>
</template>
<script setup>
const { find } = useStrapi4();
const aboutResponse = await find('about', {
populate: 'deep',
});
let about = aboutResponse.data.attributes;
useHead({
title: about?.title,
meta: [
{ name: 'description', content: about?.description },
{ name: 'og:title', content: about?.title },
{ name: 'og:description', content: about?.description },
],
})
</script>
Now we will use one of my favorite Nuxt features, Nuxt’s dynamic page generation to create dynamic pages for our collections.
inside the pages folder create an articles folder and a categories folder. Inside both of these folders create a file called [slug].vue. This will create pages
articles/[slug].vue:
<template>
<div>
<Heading
:title="article.title"
:description="article.description"
/>
<div class="px-4 py-4 flex mt-2">
<img :src="getStrapiMedia(article.author.data.attributes.avatar.data.attributes.url)"
style="border-radius: 50%; object-fit: cover" width="40" height="40" :alt="article.title" />
<div>
<span class="px-2 self-center text-neutral-700">{{ article.author.data.attributes.name }}</span>
<div v-if="article.publishedAt" class="px-2 self-center text-neutral-700">
{{ new Date(article.publishedAt).toLocaleDateString() }}
</div>
</div>
</div>
<img v-if="article.cover" class="mt-6 w-screen" :src="getStrapiMedia(article.cover.data.attributes.url)" />
<main class="mt-8">
<Blocks
v-if="article.blocks"
:blocks="article.blocks"
/>
</main>
</div>
</template>
<script setup>
import Heading from "~~/components/Heading.vue"
import Blocks from "~~/components/Blocks.vue"
const { find } = useStrapi4()
const route = useRoute();
const matchingArticle = await find("articles", {filters: {slug: route.params.slug}, populate: 'deep'})
const article = matchingArticle.data[0].attributes;
useHead({
title: article?.title,
meta: [
{ name: 'description', content: article?.description },
{ name: 'og:title', content: article?.title },
{ name: 'og:description', content: article?.description },
{ name: 'og:image', content: getStrapiMedia(article?.cover?.data?.attributes?.url)},
],
})
</script>
categories/[slug].vue:
<template>
<div>
<Heading
:title="category.name"
/>
<main>
<Articles :articles="articles || []" />
</main>
</div>
</template>
<script setup>
const props = defineProps({
global: Object
})
const { find } = useStrapi4()
const route = useRoute();
const matchingCategories = await find('categories', {filters: {slug: route.params.slug}, populate: 'deep'});
const category = matchingCategories.data[0].attributes;
const articlesResponse = await find("articles", { filters: {"category": {slug: category.slug}}, populate: 'deep'})
const articles = articlesResponse.data;
useHead({
title: `${category?.name} | ${props?.global.siteName}`,
meta: [
{ name: 'description', content: category?.description },
{ name: 'og:title', content: category?.name },
{ name: 'og:description', content: category?.description },
],
})
</script>
Lets start our app, if it isn't running, and check out the finished blog!
npm run dev

We have success! A Nuxt front-end for our Strapi blog. Check out the finished front-end code on GitHub. I hope this guide has been helpful.
This sample blog is an excellent way to jump into Nuxt, Vue, Strapi, GraphQL and even Tailwind CSS. Go ahead, challenge yourself, and make some edits!