Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 052 / 100

Building a blog with Nuxt: practical tips and trade-offs

A real Nuxt project end to end — Markdown content, SEO, prerendering and fast client-side search.

Expona Admin7 min read

Building a blog with Nuxt has become one of the most practical ways to ship a content-rich site that performs on all devices and ranks well with search engines. We've learned that choosing the right strategy for content management, SEO, rendering, and search can make the difference between a maintenance headache and a system that scales effortlessly. This article walks through a real-world Nuxt blog implementation, covering the trade-offs we've encountered at each stage.

Content — Nuxt Content for Markdown posts

Nuxt Content lets you write posts as Markdown files and query them as JSON at build time or runtime. It's a natural fit for blogs because it keeps content in your repository, versioned alongside your code, and eliminates the need for a separate headless CMS.

In practice, we structure posts under a content/blog directory, with frontmatter for metadata:

typescript
// content/blog/nuxt-performance-tips.md
---
title: "Nuxt Performance Tips"
description: "How we cut page load time by 60%"
author: "Platform Team"
published: "2024-01-15"
image: "/blog/perf-hero.jpg"
---

# Nuxt Performance Tips

Your Markdown content here...

On the page component, we fetch posts using the queryContent composable:

typescript
// app.vue (or pages/blog/[slug].vue)
const { data: post } = await useAsyncData('post', () =>
  queryContent('blog')
    .where({ _path: `/blog/${route.params.slug}` })
    .findOne()
)

const { data: posts } = await useAsyncData('posts', () =>
  queryContent('blog')
    .sort({ published: -1 })
    .limit(10)
    .find()
)

The trade-off: Nuxt Content is powerful and flexible, but it adds build complexity. Every Markdown file is parsed and indexed during the build phase. On a blog with thousands of posts, this can add 30–60 seconds to your build time. If you need dynamic content updates without rebuilding (like a comment system or real-time metrics), you'll need a hybrid approach with an external API.

In most cases, we've found the simplicity and performance gains outweigh the build-time cost. Markdown is human-readable, diffs are clean in Git, and there's no database to manage.

SEO — useSeoMeta, sitemap and OG images

A blog without proper SEO is invisible. We use three key tools in Nuxt 3: useSeoMeta for per-page metadata, dynamic sitemaps, and Open Graph images.

For each post, we set canonical URLs and metadata:

typescript
useSeoMeta({
  title: post.value.title,
  description: post.value.description,
  ogTitle: post.value.title,
  ogDescription: post.value.description,
  ogImage: `${config.public.baseUrl}${post.value.image}`,
  ogUrl: `${config.public.baseUrl}/blog/${route.params.slug}`,
  canonical: `${config.public.baseUrl}/blog/${route.params.slug}`,
  author: post.value.author,
  articlePublishedTime: post.value.published,
})

useHead({
  link: [
    {
      rel: 'canonical',
      href: `${config.public.baseUrl}/blog/${route.params.slug}`,
    },
  ],
})

For the sitemap, we generate it at build time by looping through all posts and static pages:

typescript
// server/routes/sitemap.xml.ts
export default defineEventHandler(async (event) => {
  const posts = await queryContent('blog').find()
  
  const urls = [
    { loc: '/', lastmod: new Date().toISOString().split('T')[0] },
    { loc: '/blog', lastmod: new Date().toISOString().split('T')[0] },
    ...posts.map(post => ({
      loc: `/blog/${post._path.replace('/blog/', '')}`,
      lastmod: post.published,
      priority: '0.8',
    })),
  ]

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/xml' },
    body: `

${urls.map(u => `  
    ${u.loc}
    ${u.lastmod}
  `).join('\n')}
`,
  }
})

For OG images, we have two paths: static images per post (simplest), or dynamic image generation using a library like @vercel/og. Static images are faster and don't require server compute, but dynamic ones scale better for hundreds of posts.

Trade-off: Dynamic OG image generation adds latency and server cost. If you're serving high traffic, a CDN-cached static image per post is more predictable. If you're starting out, static PNGs in your repo are fine.

Rendering — Prerender posts at build time

Nuxt's prerendering means we generate static HTML files for every post at build time, then serve them instantly at request time. This eliminates server-side rendering overhead and improves Time to First Byte (TTFB) dramatically.

We configure this in nuxt.config.ts:

typescript
export default defineNuxtConfig({
  nitro: {
    prerender: {
      crawlLinks: true,
      routes: ['/sitemap.xml', '/rss.xml'],
      ignore: ['/admin'],
    },
  },
})

With crawlLinks: true, Nuxt automatically discovers and prerender all pages linked from your root page. You can also manually specify routes:

typescript
export default defineNuxtConfig({
  nitro: {
    prerender: {
      routes: async () => {
        const posts = await queryContent('blog').find()
        return posts.map(post => `/blog/${post._path.replace('/blog/', '')}`)
      },
    },
  },
})

This generates a .output/public directory with static files, which you then deploy to S3, Netlify, Vercel, or any static host. The result: zero cold starts, instant TTFB, and minimal server CPU.

Trade-off: Prerendering works best for content that doesn't change frequently. If you publish 10 posts a day, your build time is manageable. If you're updating content every minute, prerendering adds latency between publish and live. For truly dynamic content, you'd use Incremental Static Regeneration (ISR) on Vercel or keep some pages server-rendered.

We've found prerendering scales well up to 10,000 posts, at which point build times exceed 5 minutes. Beyond that, hybrid strategies (prerender popular posts, server-render the long tail) become necessary.

Search — Client-side search over a generated index

For blogs, client-side search is often enough. We generate a JSON index at build time and serve it as a static file, then search it on the client using a library like fuse.js or lunr.js.

First, we create an index during the build:

typescript
// server/routes/search-index.json.ts
export default defineEventHandler(async (event) => {
  const posts = await queryContent('blog').find()
  
  const index = posts.map(post => ({
    id: post._id,
    slug: post._path.replace('/blog/', ''),
    title: post.title,
    description: post.description,
    content: post.body.children
      .filter(node => node.type === 'text')
      .map(node => node.value || node.text)
      .join(' ')
      .substring(0, 500),
    published: post.published,
  }))

  return index
})

On the client, we load and search it:

typescript
// composables/useSearch.ts
import Fuse from 'fuse.js'

export const useSearch = async (query: string) => {
  const { data: index } = await useFetch('/search-index.json')
  
  const fuse = new Fuse(index.value, {
    keys: ['title', 'description', 'content'],
    threshold: 0.3,
  })

  return fuse.search(query).map(result => result.item)
}

Use it in a search component:

typescript
// components/SearchBox.vue
const results = ref([])
const query = ref('')

const handleSearch = async () => {
  if (query.value.length < 2) return
  results.value = await useSearch(query.value)
}

Trade-off: Client-side search is instant and requires no server, but the index must be small enough to download quickly. For 100–1000 posts, a 100–300 KB JSON file is acceptable. Beyond that, consider server-side search with Elasticsearch or Meilisearch, or use third-party services like Algolia.

We've seen teams cache the index in browser LocalStorage and only refresh weekly, which cuts load time further. For analytics, log searches server-side to understand what your audience wants to find.

Common mistakes

  • Forgetting image optimization: Blog hero images are often high-resolution. Always use <nuxt-img> with responsive sizes and lazy loading. Unoptimized images can add seconds to page load.
  • Not setting canonical URLs: If your blog is accessible via multiple URLs (e.g., with/without trailing slash, HTTP vs HTTPS), search engines may penalize duplicate content. Always set a canonical URL.
  • Prerendering everything without a strategy: Prerendering 50,000 posts at build time makes your CI/CD pipeline slow and fragile. Segment your strategy: prerender recent and popular posts, server-render the rest.
  • Search index too large: Including full post text in the search index bloats the JSON file. Truncate to 200–500 characters and let users click through to read full content.
  • No error boundaries for missing content: If a post is deleted but a URL is still cached, your 404 handling matters. Always have a friendly fallback and redirect stale URLs to the blog home.

Key takeaways

  • Nuxt Content is ideal for blogs: Markdown is human-readable, versioned in Git, and requires no database. Accept that builds will be slightly slower as your post count grows.
  • SEO starts with metadata: use useSeoMeta, generate a dynamic sitemap, and set canonical URLs. OG images matter for social sharing; start static, upgrade to dynamic only if needed.
  • Prerendering trades build time for instant TTFB and zero server cost. It scales well up to ~10,000 posts; beyond that, consider hybrid strategies or ISR.
  • Client-side search with Fuse.js works for up to ~1,000 posts. Keep the index under 300 KB and set a search query length minimum (2–3 characters) to reduce initial load.
  • Monitor your build time, page load times, and search performance in production. Use Lighthouse, WebPageTest, and browser DevTools to catch regressions early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles