Why SSR Matters for SEO
A standard Vue.js SPA sends an almost-empty HTML shell to the browser. Googlebot sees <div id="app"></div> and has to wait for JavaScript to execute before it can crawl your content. This causes delayed indexing, poor Core Web Vitals, and lost rankings.
Nuxt 3 with SSR solves this completely. Every page is rendered on the server and sent as complete HTML — Googlebot indexes your content instantly on first crawl, no JS execution required.
| Render Mode | SEO | First Paint | Server Load |
|---|---|---|---|
| Vue SPA (CSR) | ✗ Poor | ✗ Slow | ✓ None |
| Nuxt SSR | ✓ Excellent | ✓ Instant | ~ Medium |
| Nuxt SSG (Static) | ✓ Excellent | ✓ Instant | ✓ None |
| Nuxt Hybrid | ✓ Excellent | ✓ Instant | ✓ Minimal |
Nuxt 3.13+, Node 20, @nuxtjs/seo module suite, nuxt-simple-sitemap, Vue 3 Composition API. All patterns tested on production projects.
Project Setup & nuxt.config.ts
Start with the right foundation. Install the essential SEO modules and configure nuxt.config.ts properly from day one — retrofitting SEO into an existing Nuxt app is painful.
# Create Nuxt 3 project npx nuxi@latest init my-app # Install SEO module suite (covers OG, Twitter, Schema, Sitemap) npx nuxi module add @nuxtjs/seo # Or install individually: npx nuxi module add nuxt-og-image npx nuxi module add nuxt-simple-sitemap npx nuxi module add nuxt-schema-org npx nuxi module add nuxt-robots
export default defineNuxtConfig({ // SSR is on by default — make it explicit ssr: true, // Site metadata — used by all SEO modules site: { url: 'https://yoursite.com', name: 'Your Site Name', description: 'Your site description for SEO', defaultLocale: 'en', }, modules: [ '@nuxtjs/seo', // umbrella SEO module '@nuxt/image', // image optimisation '@nuxtjs/robots', // robots.txt ], // App head defaults applied to every page app: { head: { htmlAttrs: { lang: 'en' }, charset: 'utf-8', viewport: 'width=device-width, initial-scale=1', link: [ { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, ], }, }, // Nitro — enable compression & cache headers nitro: { compressPublicAssets: true, routeRules: { '/api/**': { headers: { 'cache-control': 'no-store' } }, '/blog/**': { swr: 3600 }, // stale-while-revalidate 1hr '/_nuxt/**': { headers: { 'cache-control': 'max-age=31536000,immutable' } }, }, }, })
Dynamic Meta Tags with useSeoMeta
Nuxt 3's useSeoMeta() composable is the cleanest way to set page-level meta. It's fully typed, tree-shakeable, and works with SSR — the tags are rendered in the initial HTML, not injected by JS after load.
<script setup lang="ts"> // Fetch post data on server (SSR) const route = useRoute() const { data: post } = await useFetch(`/api/posts/${route.params.slug}`) // ✓ All tags rendered server-side in <head> useSeoMeta({ title: () => post.value?.title, description: () => post.value?.excerpt, // Open Graph ogTitle: () => post.value?.title, ogDescription: () => post.value?.excerpt, ogImage: () => post.value?.cover_image, ogType: 'article', ogUrl: () => `https://yoursite.com/blog/${route.params.slug}`, // Twitter Card twitterCard: 'summary_large_image', twitterTitle: () => post.value?.title, twitterDescription: () => post.value?.excerpt, twitterImage: () => post.value?.cover_image, // Article-specific articlePublishedTime: () => post.value?.published_at, articleAuthor: ['https://yoursite.com/about'], articleSection: () => post.value?.category, }) // Canonical URL useHead({ link: [ { rel: 'canonical', href: `https://yoursite.com/blog/${route.params.slug}` } ] }) </script>
Pass reactive values as () => post.value?.title — not post.value?.title directly. Arrow functions ensure meta updates when async data resolves, critical for SSR hydration.
Sitemap Generation
A sitemap tells search engines every URL on your site and how often they change. With @nuxtjs/seo, your sitemap is automatically generated from your Nuxt routes. For dynamic routes (like blog posts), you provide a fetch function:
sitemap: {
// Auto-discovers static routes from /pages
autoLastmod: true,
discoverImages: true,
// Dynamic routes — fetch from your Laravel API
sources: [
'/api/__sitemap__/urls',
],
defaults: {
changefreq: 'weekly',
priority: 0.8,
},
// Per-route overrides
urls: [
{ loc: '/', priority: 1.0, changefreq: 'daily' },
{ loc: '/about', priority: 0.9 },
{ loc: '/services', priority: 0.9 },
{ loc: '/blog', priority: 0.8, changefreq: 'daily' },
{ loc: '/contact', priority: 0.7 },
],
},
import { defineSitemapEventHandler } from '#imports' export default defineSitemapEventHandler(async () => { // Fetch all blog slugs from your Laravel API const posts = await $fetch<{slug: string, updated_at: string}[]>( 'https://api.yoursite.com/v1/posts/sitemap' ) return posts.map(post => ({ loc: `/blog/${post.slug}`, lastmod: post.updated_at, priority: 0.8, changefreq: 'monthly', })) })
Your sitemap is now live at https://yoursite.com/sitemap.xml — submit it to Google Search Console and Bing Webmaster Tools.
Structured Data (JSON-LD)
Structured data helps Google understand your content and can unlock rich results — article cards, FAQ dropdowns, breadcrumbs, and star ratings in SERPs. Nuxt Schema.org makes this declarative:
<script setup lang="ts"> const { data: post } = await useFetch(`/api/posts/${slug}`) // Article schema — enables rich snippets in Google useSchemaOrg([ defineArticle({ '@type': 'BlogPosting', headline: () => post.value?.title, description: () => post.value?.excerpt, image: () => post.value?.cover_image, datePublished: () => post.value?.published_at, dateModified: () => post.value?.updated_at, author: [{ '@type': 'Person', name: 'Sonu Sahani' }], publisher: { '@type': 'Organization', name: 'CodeCraft Systems', url: 'https://yoursite.com', }, }), // Breadcrumb schema defineBreadcrumb({ itemListElement: [ { name: 'Home', item: '/' }, { name: 'Blog', item: '/blog' }, { name: () => post.value?.title, item: () => `/blog/${slug}` }, ], }), ]) </script>
Use Google's Rich Results Test at search.google.com/test/rich-results to verify your structured data. Fix any errors before launch — broken schema can hurt rankings.
Core Web Vitals Optimisation
Core Web Vitals are now a confirmed Google ranking factor. Here's what to target and how to hit green on every metric with Nuxt 3:
Image Optimisation with @nuxt/image
<template> <!-- ✓ Lazy, responsive, WebP auto-converted --> <NuxtImg :src="post.cover" :alt="post.title" width="800" height="450" format="webp" loading="lazy" sizes="sm:100vw md:50vw lg:800px" /> <!-- Hero image: eager + preload for LCP --> <NuxtImg :src="hero.image" loading="eager" fetchpriority="high" preload /> </template>
Font Loading — Prevent CLS
app: {
head: {
link: [
// Preconnect — reduces DNS lookup time
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
// Preload critical font — eliminates FOUT
{
rel: 'preload',
as: 'font',
type: 'font/woff2',
href: '/fonts/your-font.woff2',
crossorigin: '',
},
],
style: [
// font-display: swap — prevents invisible text
{ children: `@font-face { font-display: swap; }` }
],
},
},
robots.txt & Canonical URLs
Control what Google crawls and prevent duplicate content issues — two of the most commonly overlooked SEO steps in Nuxt projects.
robots: {
// Block private/admin routes from indexing
disallow: ['/admin', '/dashboard', '/api', '/auth'],
allow: ['/'],
sitemap: 'https://yoursite.com/sitemap.xml',
},
// Reusable composable — use in every page export function useSeo(options: { title: string description: string path: string image?: string }) { const baseUrl = 'https://yoursite.com' const url = `${baseUrl}${options.path}` useSeoMeta({ title: options.title, description: options.description, ogTitle: options.title, ogDescription: options.description, ogImage: options.image ?? `${baseUrl}/og-default.png`, ogUrl: url, twitterCard: 'summary_large_image', }) useHead({ link: [{ rel: 'canonical', href: url }] }) } // Usage in any page: // useSeo({ title: 'My Page', description: '...', path: '/my-page' })
SEO Launch Checklist
/sitemap.xml. Check for errors after submission. Re-submit after major content changes.developers.facebook.com/tools/debug. Fix any missing or incorrect tags before sharing.search.google.com/test/rich-results. Errors here block rich snippets in SERPs./robots.txt. Never expose /admin, /api/*, or /dashboard to crawlers. Also set noindex meta on thank-you and auth pages.alt="") is valid for decorative images.SSR is the foundation. Every other SEO optimisation is multiplied by having your content in the initial HTML. Without SSR, you're building on sand — Googlebot may never index your Vue SPA content reliably.
Use useSeoMeta() on every page — unique title, description, and canonical URL. Build a reusable useSeo() composable and call it everywhere. Missing meta is the #1 Nuxt SEO mistake.
Structured data is the multiplier. JSON-LD schema doesn't just help rankings — it unlocks rich results (article cards, breadcrumbs, FAQs) that dramatically increase click-through rate from SERPs.