Skip to content
Searchpedia SEO field notes Callum Bennett Callum

Site ops

Vue.js SEO

For a Vue site you want indexed, start with history mode, per-page meta tags, and a rendering strategy chosen by how often your content changes – not by what feels modern.

Beginner3 min readUpdated 2026-07-27Notes by Callum Bennett

What I’d do first

  • Switch Vue Router to history mode so URLs are clean and bots can crawl them without hash fragments.
  • Use vue-meta or Nuxt's head() to set unique title, description, and Open Graph tags per route.
  • Choose SSR (via Nuxt) if your content updates frequently and you have over 50 pages; use prerendering for mostly static sites.
  • Add structured data in JSON-LD to help search engines identify page type and qualify for rich results.
  • Optimise performance with code splitting and lazy loading to keep Core Web Vitals in check on client-rendered pages.

The path I'd take

First, I switch Vue Router to history mode. Hash routing keeps everything after # invisible to many crawlers. I set mode: 'history' and configure my server (Nginx, Apache, or a Node server) to handle fallback routing so direct URL access works. That one change often doubles the number of discoverable pages.

Second, I implement per-page meta tags. In a standalone Vue app I use vue-meta or the newer @vueuse/head. In Nuxt I use the built-in head() function. Every route gets a unique title (under 60 characters), a meta description (under 160), a [canonical URL](/canonical-url/), and Open Graph tags for social sharing. I have a helper function that takes route params and returns the correct tags. For a blog with 500 posts I generate them dynamically from the post data.

Third, I pick a rendering strategy. If the site has content that changes at least weekly and has more than 50 pages I go with SSR via Nuxt. For example, a SaaS blog with daily articles needs SSR so Google sees the latest content on every crawl. On a 10-page company site that updates once a quarter I use prerendering with a tool like prerender-spa-plugin or Nuxt's generate mode. That produces static HTML at build time – cheaper to host and faster for crawlers. My decision rule: if you would manually update a page more than once a month, SSR is worth the server cost; otherwise prerender.

Fourth, I add structured data. I inject JSON-LD inside the <head> using the same meta management. For a product page I add Product schema; for an article I add Article. I keep the JSON-LD in a computed property that reads from the page data. That helps search engines qualify the page for rich results without extra rendering steps.

Finally, I optimise performance. I lazy load Vue components that are below the fold or non-critical. I split routes into separate chunks using dynamic imports. I serve images in next-gen formats with lazy loading. A client-rendered Vue app can still pass [Core Web Vitals](/core-web-vitals/) if the initial bundle is under 200 KB and above-the-fold content renders quickly. I then generate a sitemap with all routes and submit it in Search Console, and check the coverage report weekly for indexing errors.

Watch-outs

Hash routing is the most common trap. I have seen sites with /#/about that sit unindexed for months. Some bots do execute JavaScript and resolve the hash, but not all, and even Google may not wait for the SPA to render before capturing the URL. Always use history mode and test that your server returns the app shell for all routes.

Static meta tags across pages waste crawl budget. If every page shows the same <title>, Google may treat them as duplicates or miss the context. I once audited a Nuxt site that had 200 blog posts all titled "Blog" – half were not indexed. You must generate titles from the route data, even if it means a small overhead in the meta helper.

Client-only rendering can hide content from crawlers. Even though Google can execute JavaScript, content loaded via fetch after mount may appear after the render timeout. I have seen pages where the main article body only appeared 3 seconds after page load, and the indexed text was just the navigation. The fix: either server-render the critical content, or use a static generator to pre-render HTML. If you cannot do either, at least ensure the first API call completes quickly and the content is in the initial render.

[Canonical tags](/canonical-tags/) are easy to forget. A Vue app can generate multiple URLs for the same content due to trailing slashes, query parameters, or route aliases. Without a [canonical tag](/canonical-tag/), Google may choose the wrong version. I set <link rel="canonical" href="https://example.com/current-route" /> in my meta helper for every route, using the exact URL I want indexed.

Performance from client-side rendering can hurt Core Web Vitals directly. If the initial bundle is large and the site renders everything client-side, Largest Contentful Paint (LCP) may be slow. I keep the critical CSS inline and lazy load heavy components. For a Vue e-commerce site, I measured a 1.2 second improvement in LCP just by deferring the product recommendation widget.

What I got wrong

I left hash routing on a marketing site for two months. I thought Google would figure it out because it can run JavaScript. I was wrong. After launching a Vue SPA for a client, the Search Console coverage report stayed at zero. I spent a week debugging robots.txt and sitemap issues before checking the URLs: they all started with /#/. Switching to history mode and fixing the server config got 80% of pages indexed within a week. The delay cost us a month of organic traffic.

I assumed vue-meta would handle Open Graph tags automatically. On a blog project I set the title and description but forgot to define og:title, og:description, and og:image. Social shares looked terrible – no preview image, generic text. I had to go back and add og properties to every component. Now I always include a full set of Open Graph tags in my meta helper as defaults, then override per page.

I skipped prerendering on a static docs site because I thought SSR was the only serious option. The site had 200 pages of API documentation that changed twice a year. I built it with Nuxt in SSR mode on a small server. It worked, but the server cost was unnecessary and the build process was over-engineered. Later I switched to nuxt generate and hosted it on a CDN. Pages loaded faster and were indexed immediately as static HTML. I should have considered prerendering earlier – it was the simpler, cheaper solution.

I forgot canonical tags on a product page with colour variants. The same product existed at /product/blue and /product/red. Without a canonical, Google indexed both and treated them as [duplicate content](/duplicate-content/). When the blue variant sold out, the page was redirected to the red, but the indexed version was the blue one with a 301. I introduced a canonical pointing to the main product page to consolidate signals.

Next step

Quick answers

Can Google index a Vue app without SSR or prerendering?

Yes, but with caveats. Google can execute JavaScript and will wait for client-side rendering, but dynamic content fetched after load may not be ready before the crawler times out. For reliable indexing of critical content, I recommend SSR or prerendering. A client-only Vue app may still get indexed, but it is less predictable.

What is the difference between Nuxt SSR and Nuxt prerendering (generate)?

SSR renders each request on the server, sending fully pre-rendered HTML. It is suitable for dynamic content that changes per user or frequently. Prerendering generates static HTML files at build time for every route, which are served instantly. Use SSR for blogs and e-commerce; use prerendering for landing pages and documentation.

When should I use vue-meta vs Nuxt's built-in head()?

Use vue-meta for standalone Vue apps that are not using Nuxt. It gives you reactive meta tags per component. If you are using Nuxt, its built-in head() is simpler, requires no extra library, and is already optimised for server-side rendering. Nuxt's head() also integrates with SSR and prerendering out of the box.

How do I set up canonical URLs in a Vue app?

I define a canonical URL in the meta helper for each route using window.location.origin + route.path. I output it as a link tag inside the head. This prevents duplicate content from query parameters, trailing slashes, or route aliases. For SSR, I generate the full URL from the request object to avoid client-side variations.

Sources

Primary documentation is linked directly. Anything commercial is marked nofollow.

Notes from Callum Bennett.