SEO checklist for a Nuxt project
Most SEO regressions in a Nuxt app are not strategy problems, they are code problems: a noindex that survived a release, a canonical built from the wrong base URL, a heading tree that skips a level, a sitemap that still lists a deleted route. This checklist walks through twelve areas Google Search Central documents as technical requirements, shows the Nuxt 4 code for each one, and names the ranklint rules that fail the build when the code drifts.
Every snippet below uses APIs that ship with Nuxt 4, @nuxtjs/i18n, @nuxt/image, @nuxtjs/robots or the @ranklint/nuxt module itself. Rule ids link to the full reference in All rules.
Indexability
An indexable page has to answer three questions the same way: what the meta robots tag says, what the X-Robots-Tag header says, and what robots.txt allows. Google Search Central is explicit that a page blocked in robots.txt cannot be read, so a noindex inside it is never seen, and the URL may still appear in results without a snippet. Yandex reads the same directives but also honours Clean-param, which Googlebot ignores, so a robots.txt shared between both engines should keep that directive separate from the crawl rules that matter to Google.
The second half of indexability is server rendering. If the main content only appears after hydration, the first pass of the crawler sees an empty shell. In Nuxt that usually means a page with ssr: false or a data fetch that happens in onMounted instead of useAsyncData.
// app/pages/admin/[id].vue
useHead({
meta: [{ name: 'robots', content: 'noindex, follow' }],
})
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/robots', '@ranklint/nuxt'],
robots: {
disallow: ['/admin', '/checkout'],
sitemap: ['https://example.com/sitemap.xml'],
},
ranklint: {
site: { url: 'https://example.com', name: 'Example' },
robots: { mode: 'external' },
},
})
Only one module may own /robots.txt. With @nuxtjs/robots in the stack it writes the file, so @ranklint/nuxt runs with robots: { mode: 'external' } (or robots: false) and does not register a competing route handler. The other way round works too: drop @nuxtjs/robots, keep the default mode: 'owner', and the ranklint module serves an environment-aware robots.txt that closes non-production hosts and declares the sitemap.
How ranklint checks this
indexability:ssr-contenthttp:x-robots-consistentrobots:env-policyrobots:zone-not-blockedrobots:expected-disallowsitemap:no-noindex
Canonical and duplicates
Google treats rel=canonical as a strong hint, not a command, and picks the canonical URL itself when the signals disagree. The practical rule from Search Central: every indexable page declares a self-referencing absolute canonical, and duplicates point at the version you actually want ranked. Yandex reacts to the same tag but converges more slowly, which makes a stable canonical even more valuable on large catalogues.
Two failure modes dominate in Nuxt projects. The first is a relative or origin-less canonical built from useRoute().fullPath alone. The second is a canonical chain: page A points to B, B points to C. Build the value from useRequestURL() so the origin is correct on localhost, on preview deployments and in production.
// app/pages/products/[slug].vue
const url = useRequestURL()
const canonical = `${url.origin}${url.pathname}`
useHead({
link: [{ rel: 'canonical', href: canonical }],
})
How ranklint checks this
canonical:requiredcanonical:validcanonical:no-chainmeta:no-duplicate-titlemeta:no-duplicate-description
Title and description
Search Central asks for a unique, descriptive <title> per page and a meta description that summarises the page rather than repeating the title. Neither is a ranking lever on its own, but both control the snippet, and a missing description means Google writes one from the page body. Open Graph and Twitter tags are not search signals at all, they decide what a shared link looks like in chat apps and social feeds, which is why they belong in the same block of code.
useSeoMeta is the flat, typed way to write all of that in Nuxt 4. Keep the title near 30 to 60 characters and the description near 70 to 160, which are the defaults ranklint ships with.
// app/pages/products/[slug].vue
const route = useRoute()
const { data: product } = await useAsyncData(() => $fetch(`/api/products/${route.params.slug}`))
useSeoMeta({
title: () => `${product.value?.name} price and specs`,
description: () => product.value?.summary,
ogTitle: () => product.value?.name,
ogDescription: () => product.value?.summary,
ogImage: () => product.value?.image,
ogType: 'website',
twitterCard: 'summary_large_image',
})
How ranklint checks this
meta:title-requiredmeta:title-lengthmeta:description-requiredmeta:description-lengthmeta:og-requiredmeta:twitter-card
Headings
Google Search Central describes headings as a way to express the structure of a document, not as a styling tool. One h1 per page, no skipped levels, no empty heading elements left behind by a component that renders a title slot conditionally. Screen readers use the same tree, so the accessibility win comes for free.
The Nuxt-specific trap is layouts. A layout that renders the site name inside an h1 plus a page that renders its own h1 produces two of them on every route, and duplicate h1 text across the whole site. Keep the layout on div or p, and let the page own the single h1.
<!-- app/pages/products/[slug].vue -->
<template>
<article>
<h1>{{ product.name }}</h1>
<h2>Specifications</h2>
<h3>Dimensions</h3>
<h2>Reviews</h2>
</article>
</template>
How ranklint checks this
hreflang and i18n
The hreflang documentation on Search Central states one hard requirement: the annotations must be reciprocal. If the English page lists the German one, the German page has to list the English one back, and each set should include an x-default for the language selector or the fallback locale. Broken reciprocity is silently ignored, so the tags look present in view-source while doing nothing.
In Nuxt, @nuxtjs/i18n generates the whole set from the locale config when each locale carries a language tag in BCP 47 form and baseUrl is absolute. The composable returns a ref, so feed it into useHead as a getter: unwrapping .value once in setup freezes the annotations on the first rendered route.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
baseUrl: 'https://example.com',
defaultLocale: 'en',
strategy: 'prefix_except_default',
locales: [
{ code: 'en', language: 'en-US' },
{ code: 'de', language: 'de-DE' },
],
},
})
// app/app.vue
const head = useLocaleHead({ seo: true })
useHead(() => ({
htmlAttrs: head.value.htmlAttrs,
link: head.value.link,
meta: head.value.meta,
}))
How ranklint checks this
JSON-LD
Rich results need structured data, and Search Central prefers JSON-LD over microdata because it does not interleave with the markup. The two things that actually break in production are trivial: the JSON does not parse, usually after a string with an unescaped quote got interpolated into the script, or a required property of the chosen type is missing, so the type is dropped entirely instead of being downgraded.
The @ranklint/nuxt module ships useJsonLd(type, data), which renders the script through useHead with @context and @type filled in and validates the payload with Zod in dev only. Without the module, a plain useHead with a raw script is the equivalent.
// app/pages/products/[slug].vue
useJsonLd('Product', {
name: product.value?.name,
description: product.value?.summary,
offers: { price: product.value?.price, priceCurrency: 'EUR' },
})
// without the module
useHead({
script: [{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Example',
url: 'https://example.com',
}),
}],
})
How ranklint checks this
Breadcrumbs
Breadcrumb rich results replace the raw URL in the Google result with a readable path, and Search Central requires the BreadcrumbList type with an ordered itemListElement, each element carrying position, name and item. Yandex renders its own navigation chain from the same markup. The list has to mirror what the user sees: invisible breadcrumbs that exist only in JSON-LD are a structured data violation.
Render the visible trail with NuxtLink so internal navigation stays client-side and the crawler still gets real href values, then emit the matching JSON-LD from the same array.
<!-- app/components/Breadcrumbs.vue -->
<script setup lang="ts">
const props = defineProps<{ product: { name: string, slug: string } }>()
const crumbs = [
{ name: 'Home', path: '/' },
{ name: 'Products', path: '/products' },
{ name: props.product.name, path: `/products/${props.product.slug}` },
]
useJsonLd('BreadcrumbList', {
itemListElement: crumbs.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: `https://example.com${crumb.path}`,
})),
})
</script>
<template>
<nav aria-label="Breadcrumb">
<NuxtLink v-for="crumb in crumbs" :key="crumb.path" :to="crumb.path">
{{ crumb.name }}
</NuxtLink>
</nav>
</template>
How ranklint checks this
Sitemap
A sitemap is a discovery aid, not an index guarantee. Search Central is clear about what belongs in it: canonical, indexable, 200-answering URLs only. A noindex page in the sitemap is a contradictory signal, and a 404 in the sitemap wastes crawl budget on every fetch. The file also has to be declared in robots.txt through a Sitemap: directive, which is how Yandex discovers it when the host is not verified in a webmaster console.
The @ranklint/nuxt module serves /sitemap.xml with no configuration: it collects static routes from app/pages, absolutises every loc against site.url and escapes the XML. Dynamic routes come from explicit sources.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@ranklint/nuxt'],
ranklint: {
site: { url: 'https://example.com', name: 'Example' },
sitemap: {
path: '/sitemap.xml',
autoRoutes: true,
cacheTtl: 3600,
sources: [
'/api/seo/product-urls',
{ loc: '/pricing', changefreq: 'weekly', priority: 0.8 },
],
},
},
})
How ranklint checks this
Images
Google Images documentation asks for descriptive alt text on content images and decorative images marked with an empty alt. Layout stability matters too: intrinsic width and height let the browser reserve space, which keeps Cumulative Layout Shift low, and CLS is part of Core Web Vitals.
The third rule is about loading="lazy". Lazy loading below the fold is a win, lazy loading the hero image delays Largest Contentful Paint on every visit. NuxtImg does not add loading on its own, it is a plain attribute passed through to the img, so set loading="lazy" explicitly on the images below the fold and mark the hero with the preload prop instead.
<template>
<NuxtImg
src="/img/hero.jpg"
alt="Blue widget on a workbench"
width="1200"
height="630"
sizes="100vw md:1200px"
format="webp"
preload
/>
<NuxtImg src="/img/gallery-1.jpg" alt="Widget from the side" width="600" height="400" loading="lazy" />
</template>
How ranklint checks this
Internal links
Search Central describes crawling as link discovery: a page nobody links to is found only through the sitemap, and its importance is read as low. Internal links need a real href in the server-rendered HTML, which NuxtLink produces, unlike a div with a @click handler that navigates programmatically.
Consistency is the other half. Pick one trailing-slash convention for the whole site and keep every internal link on it, otherwise each route exists as two URLs and the signals split. Mark genuinely external links with external so Nuxt does not try to resolve them against the router.
<template>
<NuxtLink to="/products/blue-widget">Blue widget</NuxtLink>
<NuxtLink to="https://status.example.com" external target="_blank" rel="noopener">
Status page
</NuxtLink>
</template>
How ranklint checks this
HTTP and redirects
A moved URL gets a 301, and Search Central states that a permanent redirect is the strongest consolidation signal there is, while a 302 tells the crawler to keep the old URL. Chains are the second problem: every extra hop costs crawl budget and dilutes the signal, so redirect straight to the final destination instead of walking through two intermediate routes. Yandex follows the same rules but consolidates a 301 more slowly, often over several crawl cycles.
The rest of this block is hygiene that only shows up in the response: mixed content on an HTTPS page, a deleted product that returns 200 with an empty body instead of a 404, a viewport meta tag missing from a mobile-first index, and a time to first byte that grows until the crawl rate drops.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/old-pricing': { redirect: { to: '/pricing', statusCode: 301 } },
'/blog/**': { swr: 3600 },
},
app: {
head: {
meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }],
},
},
})
How ranklint checks this
links:permanent-redirectslinks:no-redirect-chainhttp:no-mixed-contenthttp:no-soft-404http:ttfb-budgetmobile:viewport
Run it locally and in CI
Everything above is checkable before a deploy. Start the app, point ranklint at it, and the crawler walks the site, runs all 43 rules and exits with code 1 when an error-severity issue is found. Without a config it audits with a minimal one derived from the URL origin, which is enough for a first pass.
npm run dev
npx ranklint audit --url http://localhost:3000
npx ranklint audit --url https://example.com --reporter markdown --json-output report.json
In a pipeline the useful shape is audit plus diff: the audit fails the job on new error issues, and the diff compares the current report against the base branch report so a review only shows what this change broke. Setup for GitHub Actions and GitLab CI, including the sticky pull request comment and SARIF upload, lives in Diff & CI.
How ranklint checks this
- Full rule reference with default severities: All rules
- Crawl, reporters and flags: ranklint audit
- Base reports, artifacts and pipeline presets: Diff & CI