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 ModeSEOFirst PaintServer Load
Vue SPA (CSR)✗ Poor✗ Slow✓ None
Nuxt SSR✓ Excellent✓ Instant~ Medium
Nuxt SSG (Static)✓ Excellent✓ Instant✓ None
Nuxt Hybrid✓ Excellent✓ Instant✓ Minimal
📦 Stack Used

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.

terminal
# 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
nuxt.config.ts
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.

pages/blog/[slug].vue
<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>
⚠️ Always Use Arrow Functions

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:

nuxt.config.ts — sitemap config
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 },
  ],
},
server/api/__sitemap__/urls.ts
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:

pages/blog/[slug].vue — Schema.org
<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>
✅ Test Your Schema

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:

LCP
Largest Contentful Paint
Target: < 2.5s
FID
First Input Delay
Target: < 100ms
CLS
Cumulative Layout Shift
Target: < 0.1
INP
Interaction to Next Paint
Target: < 200ms

Image Optimisation with @nuxt/image

components/BlogCard.vue
<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

nuxt.config.ts — font preloading
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.

nuxt.config.ts — robots config
robots: {
  // Block private/admin routes from indexing
  disallow: ['/admin', '/dashboard', '/api', '/auth'],
  allow:    ['/'],
  sitemap:  'https://yoursite.com/sitemap.xml',
},
composables/useSeo.ts — reusable canonical
// 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

01
SSR enabled + unique title/description on every page
Verify with View Source — your content must be in the HTML, not rendered by JS. No two pages should share the same title or description.
02
Canonical URL on every page
Prevents duplicate content penalties from URL parameters, trailing slashes, or multiple paths serving the same content.
03
Sitemap submitted to Google Search Console
Go to GSC → Sitemaps → Add /sitemap.xml. Check for errors after submission. Re-submit after major content changes.
04
Open Graph tags — test with Facebook Debugger
Verify og:title, og:description, og:image at developers.facebook.com/tools/debug. Fix any missing or incorrect tags before sharing.
05
Structured data — test with Google Rich Results
Validate all JSON-LD at search.google.com/test/rich-results. Errors here block rich snippets in SERPs.
06
Core Web Vitals — Lighthouse score 90+
Run Lighthouse in Chrome DevTools (mobile mode). Target 90+ for Performance, SEO, Accessibility. Fix LCP images and layout shift before launch.
07
robots.txt blocks admin/API routes
Verify at /robots.txt. Never expose /admin, /api/*, or /dashboard to crawlers. Also set noindex meta on thank-you and auth pages.
08
All images have descriptive alt text
Alt text is used by screen readers AND Google Image Search. It should describe the image content, not be keyword-stuffed. Empty alt (alt="") is valid for decorative images.
🏁 Key Takeaways

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.

👨‍💻
Sonu Sahani
Full Stack Developer · CodeCraft Systems

6+ years building production Nuxt.js, Vue.js & Laravel apps for UK, USA & global clients. Currently working with Lifelancer (UK). Certified in Nuxt.js (Coursera, Feb 2025) — writing real patterns from production projects.