Preramble: I have a lot of client-side rendered vue apps that I've built. I wanted to make them search engine friendly, but I didn't want to invest in a server-side rendered solution. I found a few articles on the subject, but they were either outdated or didn't cover all the bases. I thought I'd share my experience and what I learned.
Client-side rendered (CSR) apps are fantastic. They’re snappy, cheap to host, and a joy to build with frameworks like Vue 3. If you’re deploying to a static host like GitHub Pages, Netlify, or an S3 bucket, you probably chose CSR to keep things simple and affordable.
But then you check your analytics and realise Google isn't sending you much love. That's because, out of the box, search engine crawlers often see a blank page when they visit a CSR app. They don't always wait around for your JavaScript to load and render the content. The result? Poor visibility, low organic traffic, and a lot of wasted effort.
Don't worry, you don't need to scrap everything and switch to server-side rendering (SSR). You can absolutely make a CSR Vue site SEO-friendly. You just need to be a little strategic.
This guide walks through the essentials: dynamic metadata, sitemaps, semantic HTML, and more, with practical examples for Vue 3.
Generally, when optimizing for search engines (ignoring client side rendering for a moment), one of the most foundational steps is making sure you are choosing the right keywords to optimize for. Search engines are getting better at understanding the content of a page, but it's still a good idea to make sure you are using the right keywords - and making sure your content is relevant to those keywords.
Figuring out what your target audience is searching for; Keyword intent generally falls into categories like navigational, informational, transactional, and commercial. For example, if you are selling a product, you want to use keywords that are transactional or commercial in nature. If you are providing information, you want to use keywords that are informational in nature.
You can use tools to understand your users better like Google Analytics or Google Search Console. You want to find a balance between what your users are searching for and what you are trying to optimize for. Search volume, Keyword difficulty, and relevance are all important factors to consider when choosing keywords.
Long-tail keywords are a great way to target specific audiences. For example, if you are selling a product, you want to use keywords that are transactional in nature. If you are providing information, you want to use keywords that are informational in nature. For example, if you are selling a coffee machine, you want to use keywords like "buy coffee machine" or "coffee machine for sale." If you are providing information, you want to use keywords like "how to make coffee."
Google (or whatever search engine you are tackling) the keyword you are targeting and find out what your competition is doing. Answer questions such as:
I already touched on this but keywords are not just metadata, they are also used in the content of your page. Make sure you are using your target keywords in the content of your page. This is called keyword parity.
This is super important, but it's a bit of an art. You want to avoid keyword stuffing. You want to use your target keywords naturally; don't repeat them too often.
If every page on your site has the same <title> and <meta name="description">, search engines get confused. They might index your homepage title for every single URL, which is a recipe for SEO disaster. You need to update these tags dynamically as the user navigates your app. We'll be doing this in a dynamic way using javascript.
The best tool for the job is @vueuse/head.
npm install @vueuse/head
main.jsimport { createApp } from 'vue'
import { createHead } from '@vueuse/head'
import App from './App.vue'
const app = createApp(App)
const head = createHead()
app.use(head)
app.mount('#app')
Now you can control the <head> of any component.
<script setup>
import { useHead } from '@vueuse/head'
// This can be driven by props, a store, or API data
useHead({
title: 'The Best Coffee Shops in London',
meta: [
{ name: 'description', content: 'A guide to the best coffee in London, handpicked by locals.' },
{ property: 'og:title', content: 'Top Coffee in London' },
{ property: 'og:type', content: 'article' }
]
})
</script>
Pro Tip: For cleaner code, manage metadata from a single place using Vue Router's navigation guards. This lets you define titles and descriptions right alongside your routes.
A sitemap.xml file is your way of handing Google a list of all the pages on your site you want it to crawl. It’s a simple but crucial step.
For a site with just a handful of static pages, you can write this file by hand or with a simple build script.
const fs = require('fs')
const urls = [
'/', '/about', '/contact',
'/blog/london-coffee', '/blog/bristol-coffee'
]
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(url => `
<url>
<loc>https://yourdomain.com${url}</loc>
<changefreq>weekly</changefreq>
</url>
`).join('')}
</urlset>`
fs.writeFileSync('./dist/sitemap.xml', sitemap)
If your blog posts or products come from a headless CMS, your build script should fetch all the slugs from the API to generate the sitemap automatically.
const fs = require('fs')
const axios = require('axios')
async function generateSitemap() {
const response = await axios.get('https://yourheadlesscms.com/api/posts')
const posts = response.data
const urls = posts.map(post => `/blog/${post.slug}`)
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(url => `
<url>
<loc>https://yourdomain.com${url}</loc>
<changefreq>weekly</changefreq>
</url>
`).join('')}
</urlset>`
fs.writeFileSync('./dist/sitemap.xml', sitemap)
}
generateSitemap()
Don't Forget: Add
Sitemap: https://yourdomain.com/sitemap.xmlto yourrobots.txtfile and submit the sitemap directly to Google Search Console.
Think of it this way: clean, semantic HTML gives search bots a clear blueprint of your page. It helps them understand the hierarchy and importance of your content.
<template>
<main>
<article>
<header>
<h1>{{ post.title }}</h1>
<p>Published on: {{ post.date }}</p>
</header>
<section v-html="post.content" />
<footer>
<p>Written by {{ post.author }}</p>
</footer>
</article>
</main>
</template>
<main>, <nav>, <article>, and <section> to define the layout.<h1> per page. This is your main headline.<div> when a more specific tag exists.aria-label on navigation elements to give them unique, descriptive names.Want those fancy snippets, FAQs, and article carousels in Google search results? JSON-LD is how you get them. It's a small block of code that explicitly tells search engines what your content is about.
You can inject this script using @vueuse/head, just like your meta tags.
<script setup>
import { useHead } from '@vueuse/head'
const post = {
title: 'The Best Coffee in Bristol',
date: '2025-07-20',
author: 'Alex Green'
}
useHead({
script: [
{
type: 'application/ld+json',
children: JSON.stringify({
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": post.title,
"datePublished": post.date,
"author": {
"@type": "Person",
"name": post.author
}
})
}
]
})
</script>
This tells Google: "This page is a blog post, here's the headline, the publication date, and the author."
Test It! Use Google’s Rich Results Test to make sure your markup is valid.
This one is a classic mistake. Search bots follow href attributes on <a> tags. If your navigation is built entirely on buttons with @click events, crawlers will hit a dead end.
<!-- Bots can't see this as a link -->
<button @click="router.push('/about')">About</button>
<!-- This is perfect! It's a real link. -->
<router-link to="/about">About</router-link>
<!-- A standard anchor tag also works. -->
<a href="/about">About</a>
Vue Router is smart enough to intercept clicks on these links, so you get the speed of a single-page app while keeping your site crawlable.
Also: Use descriptive anchor text. Instead of "click here," write "read our guide to Bristol coffee." It provides context for both users and search engines.
Sometimes you might have multiple URLs that show the same content (e.g., with and without a trailing slash, or with tracking parameters). A canonical tag tells search engines which version is the "official" one to index.
useHead({
link: [
{ rel: 'canonical', href: 'https://yourdomain.com/blog/london-coffee' }
]
})
This prevents duplicate content issues and consolidates your page authority to a single URL.
Once you've done the work, you need to monitor it. These tools are essential.
| Tool | What It's For |
|---|---|
| Google Search Console | The source of truth for indexing and errors. |
| Lighthouse (Chrome DevTools) | Quick audits for SEO, performance, and accessibility. |
| Screaming Frog SEO Spider | Crawl your site like a bot to find issues. |
| PageSpeed Insights | Performance directly impacts SEO rankings. |
Check Google Search Console regularly, especially after a big update. It will tell you exactly how Google sees your site.
<title> and <meta> tags unique for every page?sitemap.xml submitted to search engines?<main>, <article>, etc.)?<router-link> or <a> tags for all internal navigation?Getting a client-side rendered Vue app to rank well isn't magic. It's about giving search engines clear, unambiguous signals about your content. You don't always need a complex SSR setup to succeed.
Focus on these fundamentals:
Make your content legible to machines, and you'll make it discoverable by humans.