Programmatic SEO with Nuxt
- Programmatic SEO (pSEO) creates unique, valuable pages from structured data at scale, turning keyword patterns into individual landing pages that each target a distinct search query
- Every generated page needs genuine unique content; swapping a product name without useful differences can produce low-value content
- Google’s scaled content abuse policy targets pages created primarily for ranking manipulation rather than helping readers, regardless of production method
- Nuxt's file-based routing combined with server routes makes pSEO straightforward, handling dynamic slugs, meta tags, and schema markup with minimal boilerplate
Template directories, integration catalogs, and currency-conversion pages can use structured data to generate pages. Each page still needs a clear purpose and useful content.
These are programmatic SEO: pages generated at scale from structured data, each targeting a specific search intent. Instead of writing every page by hand, you build a template, populate it with unique data per variation, and let the system create hundreds or thousands of pages.
Google's scaled content abuse policy targets large amounts of content created primarily to manipulate rankings, regardless of how the pages are produced.
What Programmatic SEO Requires
Check these three inputs before generating pages:
- Verified search demand for each page. People search for "Figma vs Sketch" and "Canva alternative." You can confirm this with keyword research tools. Search demand helps prioritize pages; a page can also serve navigation or an existing reader task.
- Unique content per page. Real data, original analysis, or editorial commentary that differs meaningfully between pages. Not the same paragraph with a product name swapped in.
- Structured data to populate from. Feature specs, pricing comparisons, user reviews, benchmarks. Something concrete that makes each page different from its siblings.
The Quality Line After Google's March 2024 Update
Google's March 2024 core update changed the risks of generating pages at scale. The update combined the helpful content system into the core ranking algorithm and introduced a new scaled content abuse policy that explicitly covers programmatic and AI-generated pages.
Review the scaled content abuse policy. A generation method alone does not determine whether a page violates it.
Editorial Quality Checks
Review Google’s helpful-content guidance against the reader’s task. Use these editorial checks for programmatic pages. They are not a published ranking formula:
| Signal | Thin Content | Quality Content |
|---|---|---|
| Unique value per page | Repeated template without useful differences | Relevant data or analysis specific to the page |
| Data differentiation | Same template, only the entity name changes | Real comparison data, benchmarks, screenshots |
| Editorial value | No analysis, no recommendation | Opinions, use cases, specific recommendations |
| Internal linking | Orphan pages with no link graph | Bidirectional links to related pages |
| Search intent match | Page exists for crawlers, not users | Answers the specific question the searcher has |
The line is simple: open two of your programmatic pages side by side. Remove the title and slug. If the body is interchangeable, review whether each page provides a useful distinct answer.
AI Content and Scaled Abuse
Google's spam policies define scaled content abuse as pages "generated for the primary purpose of manipulating search rankings and not helping users," and name "using generative AI tools ... to generate many pages without adding value for users" as one example. Using AI to generate unique descriptions per page does not automatically make them helpful. If the AI output is generic filler that any page could have, it fails the same quality test as template spam.
Page Types That Work
These four page types can support different reader tasks. Their existence does not establish search performance.
Feature Pages
Feature pages target queries like "extract colors from website," "detect fonts on any page," or "website performance analysis tool." Each page focuses on a single capability.
For a template directory, show relevant previews, category details, and useful related templates. Avoid pages that only swap a keyword.
pages/
├── features/
│ ├── index.vue → /features (hub page)
│ ├── [slug].vue → /features/:slug<script setup lang="ts">
const { slug } = useRoute().params
const { data: feature, error } = await useAsyncData(`feature-${slug}`, () =>
$fetch(`/api/features/${slug}`))
if (error.value)
throw error.value
if (!feature.value) {
throw createError({ statusCode: 404, statusMessage: 'Feature not found' })
}
useSeoMeta({
title: () => feature.value!.title,
description: () => feature.value!.description,
ogTitle: () => feature.value!.title,
ogDescription: () => feature.value!.description,
ogImage: () => feature.value!.ogImage,
})
useSchemaOrg([
defineSoftwareApplication({
name: feature.value.title,
description: feature.value.description,
applicationCategory: 'BrowserApplication',
operatingSystem: 'All',
offers: {
price: '0',
priceCurrency: 'USD',
},
}),
])
</script>
<template>
<article v-if="feature">
<NuxtLink to="/features" class="text-primary mb-4 inline-block">
← All features
</NuxtLink>
<h1 class="text-3xl font-bold">
{{ feature.title }}
</h1>
<p class="text-lg text-muted mt-4">
{{ feature.description }}
</p>
<!-- Unique content per feature: detailed explanation, screenshots, use cases -->
<div class="prose mt-8" v-html="feature.content" />
<!-- Cross-links to related features -->
<div v-if="feature.relatedFeatures?.length" class="mt-12">
<h2 class="text-xl font-semibold mb-4">
Related Features
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<NuxtLink
v-for="related in feature.relatedFeatures"
:key="related.slug"
:to="`/features/${related.slug}`"
class="block p-4 border border-muted rounded-lg hover:bg-muted"
>
<span class="font-medium">{{ related.title }}</span>
<p class="text-sm text-dimmed mt-1">
{{ related.excerpt }}
</p>
</NuxtLink>
</div>
</div>
</article>
</template>These initial-load excerpts require the matching app-owned endpoints and configured Schema.org module. Handle retained-component route changes separately. Use SoftwareApplication only for a real software application that meets the feature requirements. The examples do not establish eligibility for every feature page. HTML passed to v-html must be trusted editorial content or sanitized before rendering.
Comparison Pages
Comparison pages target queries like "Figma vs Sketch," "PocketUI vs CSS Peeper," or "Tailwind vs Bootstrap." These are high-intent queries from people actively evaluating options. Choose comparisons that help readers evaluate supported differences.
<script setup lang="ts">
const { slug } = useRoute().params
// slug format: "pocketui-vs-css-peeper"
const { data: comparison, error } = await useAsyncData(`compare-${slug}`, () =>
$fetch(`/api/comparisons/${slug}`))
if (error.value)
throw error.value
if (!comparison.value) {
throw createError({ statusCode: 404, statusMessage: 'Comparison not found' })
}
const { product, competitor } = comparison.value
useSeoMeta({
title: () => `${product.name} vs ${competitor.name}: Honest Comparison (2026)`,
description: () => `Compare ${product.name} and ${competitor.name} side by side. Features, pricing, performance benchmarks, and which one fits your workflow.`,
ogTitle: () => `${product.name} vs ${competitor.name}`,
ogDescription: () => comparison.value!.excerpt,
})
</script>
<template>
<article v-if="comparison">
<h1 class="text-3xl font-bold">
{{ product.name }} vs {{ competitor.name }}
</h1>
<p class="text-lg text-muted mt-4">
{{ comparison.intro }}
</p>
<!-- Feature comparison table with real data -->
<table class="w-full mt-8 border-collapse">
<thead>
<tr class="border-b border-muted">
<th class="text-left py-3 px-4">
Feature
</th>
<th class="text-left py-3 px-4">
{{ product.name }}
</th>
<th class="text-left py-3 px-4">
{{ competitor.name }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in comparison.features"
:key="row.name"
class="border-b border-muted"
>
<td class="py-3 px-4 font-medium">
{{ row.name }}
</td>
<td class="py-3 px-4">
{{ row.product }}
</td>
<td class="py-3 px-4">
{{ row.competitor }}
</td>
</tr>
</tbody>
</table>
<!-- Unique editorial content per comparison -->
<div class="prose mt-8" v-html="comparison.analysis" />
<!-- Link to feature pages mentioned in this comparison -->
<div v-if="comparison.relatedFeatures?.length" class="mt-8">
<h2 class="text-xl font-semibold mb-4">
Features Mentioned
</h2>
<NuxtLink
v-for="feature in comparison.relatedFeatures"
:key="feature.slug"
:to="`/features/${feature.slug}`"
class="text-primary hover:underline mr-4"
>
{{ feature.title }}
</NuxtLink>
</div>
</article>
</template>Notice the cross-links at the bottom. Comparison pages link to the feature pages they reference, and feature pages can link back to relevant comparisons. Bidirectional linking creates a tightly connected cluster.
Alternative Pages
Alternative pages serve readers evaluating another product. Describe real differences and support comparisons with current evidence.
<script setup lang="ts">
const { slug } = useRoute().params
const { data: alternative, error } = await useAsyncData(`alt-${slug}`, () =>
$fetch(`/api/alternatives/${slug}`))
if (error.value)
throw error.value
if (!alternative.value) {
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
}
useSeoMeta({
title: () => `Best ${alternative.value!.competitor.name} Alternative in 2026`,
description: () => `Looking for a ${alternative.value!.competitor.name} alternative? See how ${alternative.value!.product.name} compares on features, pricing, and performance.`,
})
</script>
<template>
<article v-if="alternative">
<h1 class="text-3xl font-bold">
Best {{ alternative.competitor.name }} Alternative in 2026
</h1>
<p class="text-lg text-muted mt-4">
{{ alternative.intro }}
</p>
<!-- Why people switch: real reasons, not generic marketing copy -->
<h2 class="text-2xl font-semibold mt-8">
Why People Switch from {{ alternative.competitor.name }}
</h2>
<ul class="mt-4 space-y-3">
<li v-for="reason in alternative.switchReasons" :key="reason" class="flex gap-2">
<span class="text-success">✓</span>
<span>{{ reason }}</span>
</li>
</ul>
<!-- Side-by-side comparison -->
<div class="prose mt-8" v-html="alternative.comparison" />
<!-- Link to the full comparison page if one exists -->
<NuxtLink
v-if="alternative.comparisonSlug"
:to="`/compare/${alternative.comparisonSlug}`"
class="inline-block mt-6 text-primary hover:underline"
>
See the full {{ alternative.product.name }} vs {{ alternative.competitor.name }} comparison →
</NuxtLink>
</article>
</template>The alternative page links to the full comparison page when one exists. This creates a natural progression: visitor lands on the alternative page, reads the summary, clicks through to the detailed comparison.
Category Pages
Category pages (hub pages) target broad queries like "best chrome extensions for designers" or "design inspiration tools 2026." They serve as navigation hubs that distribute PageRank to your feature and comparison pages.
<script setup lang="ts">
const { slug } = useRoute().params
const { data: category, error } = await useAsyncData(`category-${slug}`, () =>
$fetch(`/api/categories/${slug}`))
if (error.value)
throw error.value
if (!category.value) {
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
}
useSeoMeta({
title: () => category.value!.title,
description: () => category.value!.description,
})
</script>
<template>
<div v-if="category">
<h1 class="text-3xl font-bold">
{{ category.title }}
</h1>
<p class="text-lg text-muted mt-4">
{{ category.intro }}
</p>
<!-- Editorial content unique to this category -->
<div class="prose mt-8" v-html="category.content" />
<!-- Links to all feature pages in this category -->
<h2 class="text-2xl font-semibold mt-12">
Tools in This Category
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
<NuxtLink
v-for="feature in category.features"
:key="feature.slug"
:to="`/features/${feature.slug}`"
class="block p-6 border border-muted rounded-lg hover:bg-muted"
>
<h3 class="font-semibold">
{{ feature.title }}
</h3>
<p class="text-sm text-dimmed mt-2">
{{ feature.excerpt }}
</p>
</NuxtLink>
</div>
<!-- Links to relevant comparisons -->
<div v-if="category.comparisons?.length" class="mt-12">
<h2 class="text-2xl font-semibold">
Comparisons
</h2>
<ul class="mt-4 space-y-2">
<NuxtLink
v-for="comp in category.comparisons"
:key="comp.slug"
:to="`/compare/${comp.slug}`"
class="text-primary hover:underline block"
>
{{ comp.title }}
</NuxtLink>
</ul>
</div>
</div>
</template>Category pages connect feature pages, comparison pages, and other categories. This hub and spoke architecture is explained in detail in the internal linking guide.
Nuxt Implementation
Example file structure for app-owned integrations. The excerpts below are not a complete application; comparison, alternative, and category datasets and endpoints must be supplied:
pages/
├── features/
│ ├── index.vue → /features (hub)
│ └── [slug].vue → /features/:slug
├── compare/
│ ├── index.vue → /compare (hub)
│ └── [slug].vue → /compare/:slug
├── alternatives/
│ ├── index.vue → /alternatives (hub)
│ └── [slug].vue → /alternatives/:slug
├── categories/
│ ├── index.vue → /categories (hub)
│ └── [slug].vue → /categories/:slug
server/
├── api/
│ ├── features/
│ │ └── [slug].get.ts → Feature data endpoint
│ ├── comparisons/
│ │ └── [slug].get.ts → Comparison data endpoint
│ ├── alternatives/
│ │ └── [slug].get.ts → Alternative data endpoint
│ ├── categories/
│ │ └── [slug].get.ts → Category data endpoint
│ └── __sitemap__/
│ └── urls.ts → Dynamic sitemap sourceData Source
This feature-only excerpt requires the sample data file below. Other endpoints need their own data contracts:
import { features } from '../../data/features'
export default defineEventHandler((event) => {
const slug = getRouterParam(event, 'slug')
const feature = features.find(f => f.slug === slug)
if (!feature) {
throw createError({ statusCode: 404, statusMessage: 'Feature not found' })
}
return feature
})For a simple setup, a static data file works:
export const features = [
{
slug: 'color-extraction',
title: 'Color Extraction',
description: 'Extract dominant colors from any website or image.',
content: '<p>Our color extraction engine analyzes...</p>',
ogImage: 'https://example.com/og/features/color-extraction.png',
relatedFeatures: [
{ slug: 'font-detection', title: 'Font Detection', excerpt: 'Identify any font on the web.' },
],
},
{
slug: 'font-detection',
title: 'Font Detection',
description: 'Identify fonts used on any website instantly.',
content: '<p>Font detection works by analyzing computed styles...</p>',
ogImage: 'https://example.com/og/features/font-detection.png',
relatedFeatures: [
{ slug: 'color-extraction', title: 'Color Extraction', excerpt: 'Extract dominant colors.' },
],
},
// Supply accurate updatedAt fields if these records use sitemap lastmod
]As your dataset grows, move this to a database or CMS. The page components stay the same because they fetch from the API layer.
Page Component Pattern
Each initial-load page excerpt uses this pattern. Its endpoint, dataset, styling, and referenced components belong to your app:
- Extract the slug from the route
- Fetch data using
useAsyncData()with a unique key - Propagate upstream errors; return 404 for a successful absent record
- Set
useSeoMeta()with unique title and description - Add
useSchemaOrg()where appropriate - Render the unique content
- Include internal links to related pages
Check that each generated route returns useful initial HTML, the intended metadata, and working links. Templates do not guarantee crawling or indexing.
Generating Routes for Sitemap
Install and configure @nuxtjs/sitemap first. It provides defineSitemapEventHandler and supports dynamic URL sources through server endpoints.
export default defineNuxtConfig({
modules: ['@nuxtjs/sitemap'],
sitemap: {
sources: ['/api/__sitemap__/urls']
}
})Supply every relevant page type from your app’s data. This sitemap-source excerpt requires feature, comparison, alternative, and category datasets with slug and accurate updatedAt fields. These datasets are not all implemented here. The sample feature records lack update dates, so their map omits lastmod. Add it only when you supply accurate modification dates:
import { alternatives } from '../../data/alternatives'
import { categories } from '../../data/categories'
import { comparisons } from '../../data/comparisons'
import { features } from '../../data/features'
export default defineSitemapEventHandler(async () => {
const urls = [
...features.map(f => ({
loc: `/features/${f.slug}`,
})),
...comparisons.map(c => ({
loc: `/compare/${c.slug}`,
lastmod: c.updatedAt,
})),
...categories.map(c => ({ loc: `/categories/${c.slug}`, lastmod: c.updatedAt })),
...alternatives.map(a => ({
loc: `/alternatives/${a.slug}`,
lastmod: a.updatedAt,
})),
]
return urls
})If you prefer static generation over server-side rendering, tell Nuxt to prerender all your programmatic routes:
export default defineNuxtConfig({
nitro: {
prerender: {
routes: ['/features', '/compare', '/alternatives', '/categories'],
crawlLinks: true, // Follows links on prerendered pages to discover more
}
}
})With crawlLinks: true, Nitro follows eligible rendered anchor hrefs. Verify coverage; unlinked URLs need explicit enumeration. This is why hub pages that link to every child page are essential: they serve double duty as a user-facing directory and a prerender discovery mechanism.
Managing Crawl Budget at Scale
Google’s crawl-budget guidance applies to large or frequently changing sites. It does not define a universal per-session request quota.
Avoid unbounded URL traps. Canonical targets and pages containing canonical annotations must remain crawlable. Use accurate lastmod dates for meaningful content changes. Split sitemaps when required by protocol limits or useful for management, not a universal 10,000-page rule.
The Zero-Click and AI Search Factor
Evaluate generated pages against reader tasks. Google’s generative AI guidance emphasizes useful, accurate content. Structured data and headings do not guarantee citations in AI answers.
Avoiding Thin Content
Create pages for readers rather than ranking manipulation. Review repeated templates for useful differences; a template or page count alone does not establish a policy violation.
Each page needs:
- Unique descriptive paragraphs. Include useful content that differs between pages. Google does not set a minimum word count for page quality. Not the product name swapped into the same template sentence.
- Real data or comparisons. Benchmarks, feature matrices, pricing tables, screenshots. Something a visitor cannot get from reading the product's homepage.
- Editorial content that adds value. A recommendation, an opinion, a use case breakdown. Something that demonstrates expertise.
<template>
<article>
<h1>Color Extraction</h1>
<!-- Content specific to this feature -->
<p>
Color extraction analyzes the DOM and computed styles of any webpage
to identify the dominant color palette. Unlike browser devtools, which
require you to inspect elements one at a time, our engine scans the
entire page and groups colors by frequency and visual weight.
</p>
<p>
This is particularly useful for design audits. Upload a competitor's
URL and receive a complete color breakdown within seconds, including
hex values, RGB, and HSL representations.
</p>
<!-- Real data: actual output from the feature -->
<!-- Insert an app-owned demonstration here -->
<!-- Editorial: specific use cases and recommendations -->
<h2>When to Use Color Extraction</h2>
<p>
The most common use case is competitive analysis. Design teams use it
to reverse-engineer the color strategies of successful sites...
</p>
</article>
</template><template>
<article>
<h1>{{ feature.name }}</h1>
<!-- Thin content: same sentence on every page with name swapped -->
<p>
{{ feature.name }} is one of our best features.
Try {{ feature.name }} today and see the difference.
</p>
<!-- No real data, no editorial value -->
<button>Get Started</button>
</article>
</template>Internal Linking for pSEO
Internal links connect generated pages for navigation and discovery. They do not guarantee authority or indexing.
Link Topology
Build bidirectional links between your page types:
Category Pages
├── → Feature Pages
│ ├── → Related Feature Pages (siblings)
│ ├── → Comparison Pages (that reference this feature)
│ └── → Category Page (parent)
├── → Comparison Pages
│ ├── → Feature Pages (both products compared)
│ ├── → Alternative Pages (related alternatives)
│ └── → Category Page (parent)
└── → Alternative Pages
├── → Comparison Page (full comparison)
├── → Feature Pages (differentiating features)
└── → Category Page (parent)Every page links up (to its category/hub), sideways (to siblings and related pages), and down (to more specific pages). This provides crawl paths between related pages; it does not guarantee that Google follows every link.
Automated Related Links
Generate related links from app-owned tags or categories when those relationships help readers. The sample feature dataset does not define tags or excerpts, so a tag-based endpoint is not supplied here.
Common Mistakes
Generating Thousands of Near-Identical Pages
Repeated pages without useful differences can violate Google’s scaled content abuse policy when created to manipulate rankings. Start with useful pages and expand when each new page serves a distinct task.
Missing Canonical Tags on URL Variations
If comparison URL variants show equivalent content, choose a preferred URL. Validate the slug pair before normalizing it. Use a canonical preference or a permanent redirect for a moved URL. The undefined-slug normalization alternative is omitted; see the canonical guide for reactive head setup.
Not Submitting Generated URLs to Sitemap
Dynamic route files do not publish a list of all generated URLs. Include important generated URLs in your sitemap as shown in the sitemap generation section above, and make sure your hub pages link to every generated page.
No Internal Links Between Generated Pages
Pages without useful links are harder for readers to navigate. Every feature page should link to related features, every comparison page should link to the features it discusses, and hub pages should link to everything.
Keyword-Stuffed URLs
Keep slugs descriptive, clean, and readable.
/features/color-extraction
/compare/figma-vs-sketch
/alternatives/colorzilla/features/best-color-extraction-tool-free-online-2026
/compare/figma-vs-sketch-comparison-review-which-is-better
/alternatives/best-colorzilla-alternative-free-chrome-extensionLong, keyword-stuffed URLs look spammy to both users and search engines. Do not assume a universal URL length or click-through effect. Read more about URL structure best practices.
Checklist
- Confirm each generated page serves a reader task; use measured search demand when prioritizing search acquisition
- Every page contains useful content specific to its subject: real data, editorial analysis, or specific use cases
- Metadata describes each page; structured data matches its actual content and supported feature requirements
- Feature, comparison, alternative, and category pages link to each other bidirectionally, not orphaned
- Register generated URLs in the sitemap via
/api/__sitemap__/urls, or make them discoverable throughcrawlLinksprerendering - Canonical preferences consolidate comparison URL variations only when their rendered content is equivalent
- Slugs are short and readable, not keyword-stuffed
Pre-launch setup, post-launch verification, and ongoing monitoring. Interactive checklist with links to every guide.