Programmatic SEO with Vue · Nuxt SEO

-
-
-
-

[1.4K](https://github.com/harlan-zw/nuxt-seo)

[Nuxt SEO on GitHub](https://github.com/harlan-zw/nuxt-seo)

Learn SEO

Master search optimization

Nuxt

 Vue

-
-
-
-
-
-
-

-
-
-
-
-
-
-

-
-
-

-
-
-
-
-
-
-
-
-
-
-

-
-
-

-
-
-
-
-
-
-
-
-

1.
2.
3.
4.
5.

# Programmatic SEO with Vue

Build SEO pages at scale with Vue dynamic routes. Feature pages, comparison pages, alternative pages, and category pages that rank.

[![Harlan Wilton](https://avatars.githubusercontent.com/u/5326365?v=4)Harlan Wilton](https://x.com/harlan-zw)16 mins read Published Mar 23, 2026

What you'll learn

- 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 into the same template produces thin content that Google will filter or penalize
- Google's March 2024 core update and [scaled content abuse policy](https://developers.google.com/search/docs/essentials/spam-policies#scaled-content-abuse) explicitly target low-quality programmatic pages, including AI-generated ones
- Vue Router's dynamic route parameters combined with a backend API or SSR server make pSEO straightforward, handling dynamic slugs, meta tags, and schema markup with minimal boilerplate

Canva ranks for over 200,000 keywords through [24,000+ template pages](https://www.searchpilot.com/blog/canva-programmatic-seo/), each targeting a query like "instagram story template" or "birthday invitation maker." Zapier drives millions of monthly visits through integration pages ("connect Slack to Google Sheets") built from their app database. Wise (formerly TransferWise) generated [currency conversion pages](https://www.productledseo.com/blog/product-led-seo-vs-content-led-seo/) for every possible pair, capturing searchers mid-transaction.

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.

The strategy works. It also has a body count. Google's March 2024 core update [deindexed hundreds of sites](https://www.searchenginejournal.com/google-manual-actions-march-2024/510651/) running low-quality programmatic content. The difference between Canva's success and a manual action comes down to one thing: whether each page genuinely helps the person who lands on it.

## [What Programmatic SEO Requires](#what-programmatic-seo-requires)

Three conditions determine whether pSEO will work for your site:

1. **Verified search demand for each page.** People search for "Figma vs Sketch" and "Canva alternative." You can confirm this with keyword research tools. If nobody searches for the variation, the page has no purpose.
2. **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.
3. **Structured data to populate from.** Feature specs, pricing comparisons, user reviews, benchmarks. Something concrete that makes each page different from its siblings.

Eli Schwartz, author of _Product-Led SEO_, frames this as the distinction between content-led and product-led approaches:

> "The best programmatic SEO pages are features of the product itself. Zapier's integration pages aren't marketing content; they're the product. That's why they survive every algorithm update." - [Eli Schwartz, Product-Led SEO](https://www.productledseo.com/blog/product-led-seo-vs-content-led-seo/)
## [The Quality Line After Google's March 2024 Update](#the-quality-line-after-googles-march-2024-update)

Google's [March 2024 core update](https://developers.google.com/search/blog/2024/03/march-2024-core-update) changed the risk calculus for pSEO. The update combined the helpful content system into the core ranking algorithm and introduced a new [scaled content abuse policy](https://developers.google.com/search/docs/essentials/spam-policies#scaled-content-abuse) that explicitly covers programmatic and AI-generated pages.

The results were severe. [Search Engine Journal reported](https://www.searchenginejournal.com/google-manual-actions-march-2024/510651/) that sites receiving manual actions saw traffic drops of 80% to 100%, with many deindexed. The pattern: sites generating thousands of pages from templates with minimal unique content per page.

### [What Google Filters](#what-google-filters)

Google's [helpful content guidelines](https://developers.google.com/search/docs/fundamentals/creating-helpful-content) ask a direct question: "Would someone coming to this page feel they've had a satisfying experience?" For programmatic pages specifically, the signals that trigger quality filters include:

| Signal | Thin Content | Quality Content |
| --- | --- | --- |
| **Unique text per page** | < 100 words of unique content | 300+ words of genuinely different text |
| **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 content is interchangeable, you have thin content and Google will eventually catch it.

### [AI Content and Scaled Abuse](#ai-content-and-scaled-abuse)

Google's [spam policies](https://developers.google.com/search/docs/essentials/spam-policies#scaled-content-abuse) now explicitly cover "using automation, including AI, to generate content at scale where the primary purpose is to manipulate search rankings." 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](#page-types-that-work)

Four page types consistently perform well in pSEO. Each targets a different stage of the buyer's journey.

_Illustrative traffic distribution across pSEO page types for a mid-stage SaaS product. Alternative and comparison pages capture disproportionate branded traffic due to competitor name queries. Based on patterns from [SearchPilot](https://www.searchpilot.com/blog/canva-programmatic-seo/), [Minuttia](https://minuttia.com/product-alternatives-seo-study/)._

### [Feature Pages](#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.

Canva's template pages are the canonical example. Each of their [24,000+ pages](https://www.searchpilot.com/blog/canva-programmatic-seo/) includes unique template previews, category-specific copy, and related template suggestions. The key: every page has content you can only find on that specific page.

```
src/
├── pages/
│   ├── features/
│   │   ├── index.vue                → /features (hub page)
│   │   └── [slug].vue               → /features/:slug
```

src/pages/features/[slug].vue

```
<script setup lang="ts">
import { defineSoftwareApplication, useSchemaOrg } from '@unhead/schema-org/vue'
import { useSeoMeta } from '@unhead/vue'
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()
const slug = route.params.slug as string

const feature = ref<any>(null)

const response = await fetch(\`/api/features/${slug}\`)
if (!response.ok) {
  router.replace('/404')
}
else {
  feature.value = await response.json()
}

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">
    <RouterLink to="/features" class="text-primary mb-4 inline-block">
      ← All features
    </RouterLink>

    <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">
        <RouterLink
          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>
        </RouterLink>
      </div>
    </div>
  </article>
</template>
```

`useSeoMeta()` from `@unhead/vue` sets unique meta tags per feature. `useSchemaOrg()` with `defineSoftwareApplication()` adds structured data for rich results. The related features section creates internal links between sibling pages, reinforcing the topic cluster.

### [Comparison Pages](#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. If you don't create the comparison page, a third-party blog will rank for your product name instead.

src/pages/compare/[slug].vue

```
<script setup lang="ts">
import { useSeoMeta } from '@unhead/vue'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()
const slug = route.params.slug as string

// slug format: "pocketui-vs-css-peeper"
const comparison = ref<any>(null)

const response = await fetch(\`/api/comparisons/${slug}\`)
if (!response.ok) {
  router.replace('/404')
}
else {
  comparison.value = await response.json()
}

const product = computed(() => comparison.value?.product)
const competitor = computed(() => comparison.value?.competitor)

useSeoMeta({
  title: () => \`${product.value?.name} vs ${competitor.value?.name}: Honest Comparison (2026)\`,
  description: () => \`Compare ${product.value?.name} and ${competitor.value?.name} side by side. Features, pricing, performance benchmarks, and which one fits your workflow.\`,
  ogTitle: () => \`${product.value?.name} vs ${competitor.value?.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>
      <RouterLink
        v-for="feature in comparison.relatedFeatures"
        :key="feature.slug"
        :to="\`/features/${feature.slug}\`"
        class="text-primary hover:underline mr-4"
      >
        {{ feature.title }}
      </RouterLink>
    </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)

Alternative pages target "X alternative" queries. [Minuttia's analysis of 1,000+ "alternatives" keywords](https://minuttia.com/product-alternatives-seo-study/) found that these queries have significantly higher CPC than generic feature queries, indicating strong commercial intent. When someone searches "ColorZilla alternative," they understand the problem and are evaluating solutions.

src/pages/alternatives/[slug].vue

```
<script setup lang="ts">
import { useSeoMeta } from '@unhead/vue'
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()
const slug = route.params.slug as string

const alternative = ref<any>(null)

const response = await fetch(\`/api/alternatives/${slug}\`)
if (!response.ok) {
  router.replace('/404')
}
else {
  alternative.value = await response.json()
}

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 -->
    <RouterLink
      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 →
    </RouterLink>
  </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)

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.

src/pages/categories/[slug].vue

```
<script setup lang="ts">
import { useSeoMeta } from '@unhead/vue'
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()
const slug = route.params.slug as string

const category = ref<any>(null)

const response = await fetch(\`/api/categories/${slug}\`)
if (!response.ok) {
  router.replace('/404')
}
else {
  category.value = await response.json()
}

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">
      <RouterLink
        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>
      </RouterLink>
    </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">
        <RouterLink
          v-for="comp in category.comparisons"
          :key="comp.slug"
          :to="\`/compare/${comp.slug}\`"
          class="text-primary hover:underline block"
        >
          {{ comp.title }}
        </RouterLink>
      </ul>
    </div>
  </div>
</template>
```

Category pages are the glue connecting everything. Each one links to multiple feature pages, comparison pages, and other categories. This hub and spoke architecture is explained in detail in the

.
## [Vue Implementation](#vue-implementation)

Complete file structure for a programmatic SEO setup with Vue Router:

```
src/
├── 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/
├── routes/
│   ├── features/
│   │   └── [slug].ts                → Feature data endpoint
│   ├── comparisons/
│   │   └── [slug].ts                → Comparison data endpoint
│   ├── alternatives/
│   │   └── [slug].ts                → Alternative data endpoint
│   └── categories/
│       └── [slug].ts                → Category data endpoint
```

Vue Router configuration connecting routes to their page components:

src/router/index.ts

```
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/features', component: () => import('../pages/features/index.vue') },
  { path: '/features/:slug', component: () => import('../pages/features/[slug].vue') },
  { path: '/compare', component: () => import('../pages/compare/index.vue') },
  { path: '/compare/:slug', component: () => import('../pages/compare/[slug].vue') },
  { path: '/alternatives', component: () => import('../pages/alternatives/index.vue') },
  { path: '/alternatives/:slug', component: () => import('../pages/alternatives/[slug].vue') },
  { path: '/categories', component: () => import('../pages/categories/index.vue') },
  { path: '/categories/:slug', component: () => import('../pages/categories/[slug].vue') },
]

export const router = createRouter({
  history: createWebHistory(),
  routes,
})
```

### [Data Source](#data-source)

Your data can come from anywhere: a database, a CMS, a JSON file, or a third-party API. An Express (or h3/Hono) server route fetches and shapes it:

server/routes/features/[slug].ts

```
import express from 'express'
import { features } from '../data/features'

const router = express.Router()

router.get('/api/features/:slug', (req, res) => {
  const feature = features.find(f => f.slug === req.params.slug)

  if (!feature) {
    return res.status(404).json({ error: 'Feature not found' })
  }

  // Auto-generate related features based on shared tags
  const related = features
    .filter(f => f.slug !== req.params.slug)
    .filter(f => f.tags.some((tag: string) => feature.tags.includes(tag)))
    .slice(0, 4)
    .map(f => ({ slug: f.slug, title: f.title, excerpt: f.excerpt }))

  res.json({ ...feature, relatedFeatures: related })
})

export default router
```

For a simple setup, a static data file works:

server/data/features.ts

```
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: '/og/features/color-extraction.png',
    tags: ['design', 'color'],
    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: '/og/features/font-detection.png',
    tags: ['design', 'typography'],
    relatedFeatures: [
      { slug: 'color-extraction', title: 'Color Extraction', excerpt: 'Extract dominant colors.' },
    ],
  },
  // ... more features
]
```

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](#page-component-pattern)

Every programmatic page follows the same pattern:

1. Extract the slug from the route using `useRoute().params` from `vue-router`
2. Fetch data using the native `fetch()` API with `await` in `<script setup>`
3. Redirect to 404 using `router.replace('/404')` if the slug does not match any data
4. Set `useSeoMeta()` from `@unhead/vue` with unique title and description
5. Add `useSchemaOrg()` from `@unhead/schema-org/vue` where appropriate
6. Render the unique content
7. Include `<RouterLink>` elements to related pages

This pattern ensures every page is SSR-rendered with correct meta tags (when using Vite SSR or a framework like Quasar), discoverable by search engines, and connected to the broader site through internal links.

## [Generating Routes for Sitemap](#generating-routes-for-sitemap)

Google needs to know your programmatic pages exist. With `vite-plugin-sitemap`, you provide all your dynamic routes at build time:

vite.config.ts

```
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import Sitemap from 'vite-plugin-sitemap'
import { alternatives } from './src/data/alternatives'
import { comparisons } from './src/data/comparisons'
import { features } from './src/data/features'

export default defineConfig({
  plugins: [
    vue(),
    Sitemap({
      hostname: 'https://yoursite.com',
      dynamicRoutes: [
        ...features.map(f => \`/features/${f.slug}\`),
        ...comparisons.map(c => \`/compare/${c.slug}\`),
        ...alternatives.map(a => \`/alternatives/${a.slug}\`),
      ],
    }),
  ],
})
```

For sites where data comes from a database or API, fetch the slugs at build time in the [Vite](https://vite.dev) config:

vite.config.ts

```
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import Sitemap from 'vite-plugin-sitemap'

export default defineConfig(async () => {
  const [featuresRes, comparisonsRes] = await Promise.all([
    fetch('https://yoursite.com/api/features/all'),
    fetch('https://yoursite.com/api/comparisons/all'),
  ])
  const features = await featuresRes.json()
  const comparisons = await comparisonsRes.json()

  return {
    plugins: [
      vue(),
      Sitemap({
        hostname: 'https://yoursite.com',
        dynamicRoutes: [
          ...features.map((f: any) => \`/features/${f.slug}\`),
          ...comparisons.map((c: any) => \`/compare/${c.slug}\`),
        ],
      }),
    ],
  }
})
```

### [Static Generation Alternative](#static-generation-alternative)

If you prefer static generation over server-side rendering, `vite-ssg` (Vue's static site generation approach) pre-renders all your routes at build time. Configure it with all your dynamic routes:

vite.config.ts

```
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [vue()],
  ssgOptions: {
    includedRoutes(paths) {
      return [
        ...paths,
        ...features.map(f => \`/features/${f.slug}\`),
        ...comparisons.map(c => \`/compare/${c.slug}\`),
      ]
    },
  },
})
```

With static generation, your hub pages that link to every child page serve double duty as a user-facing directory and a pre-render discovery mechanism, since crawlers follow links between pages.

## [Managing Crawl Budget at Scale](#managing-crawl-budget-at-scale)

When you launch 1,000+ programmatic pages, crawl budget becomes a real constraint. Googlebot allocates a limited number of requests per crawl session to your domain. Wasting those requests on thin or duplicate pages means your important pages get crawled less frequently.

[Lumar's research on crawl budget management](https://www.lumar.io/blog/seo/crawl-budget-management/) identifies the key factors:

- **Index bloat.** Pages that exist in your sitemap but provide no unique value waste crawl budget. Google will eventually stop bothering to crawl them, and that slowdown can spill over to your good pages.
- **Crawl traps.** Faceted navigation, infinite pagination, and parameter variations (like `/compare/figma-vs-sketch` and `/compare/sketch-vs-figma`) create exponential URL growth. Use canonical tags or redirects to collapse these.
- **Freshness signals.** `lastmod` in your sitemap tells Google which pages have changed. If every page claims daily updates, Google ignores the signal entirely.

For sites with 10,000+ programmatic pages, consider splitting your sitemap into multiple sitemaps by page type (`sitemap-features.xml`, `sitemap-comparisons.xml`) so you can monitor crawl patterns per section in Google Search Console.

## [The Zero-Click and AI Search Factor](#the-zero-click-and-ai-search-factor)

Programmatic SEO faces a structural headwind: [Gartner predicts](https://www.gartner.com/en/newsroom/press-releases/2024-02-19-gartner-predicts-search-engine-volume-will-drop-25-percent-by-2026-due-to-ai-chatbots) a 25% drop in traditional search engine volume by 2026 as AI assistants answer queries directly. [SparkToro's data](https://sparktoro.com/blog/zero-click-searches-are-on-the-rise-again/) shows zero-click searches continuing to rise, with AI Overviews absorbing clicks that would have gone to organic results.

Kevin Indig describes this as the shift from rankings to visibility:

> "Rankings are becoming a proxy metric. What matters now is Visibility Share: how often your brand appears across all information surfaces, including AI-generated answers, featured snippets, and social feeds." - [Kevin Indig, The End of the Click Economy](https://www.kevin-indig.com/the-end-of-the-click-economy/)

What this means for pSEO in practice:

- **Comparison and alternative pages remain viable.** [Minuttia's data](https://minuttia.com/product-alternatives-seo-study/) shows these keywords maintain high CPC and conversion rates because they represent decision-stage queries that AI overviews handle poorly. A searcher comparing two specific products wants depth, not a summary.
- **Simple fact queries are vulnerable.** "What is feature" pages that provide a single-paragraph answer will be absorbed by AI Overviews. Your feature pages need depth that a snippet cannot reproduce.
- **Structured data feeds AI.** Pages with schema markup, clear headings, and data tables are more likely to be cited as sources in AI-generated answers, maintaining some visibility even in zero-click scenarios.

## [Avoiding Thin Content](#avoiding-thin-content)

Google's helpful content system evaluates pages on a site-wide basis. A large number of thin programmatic pages can drag down the rankings of your entire site, including pages that are genuinely useful. This is the biggest risk with pSEO.

Each page needs:

- **Unique descriptive paragraphs.** At minimum, 200 to 300 words of content that differs between pages. 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.

✅ Correct

❌ Wrong

```
<template>
  <article>
    <h1>Color Extraction</h1>

    <!-- Unique content: 300+ words 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 -->
    <ColorPaletteDemo :url="feature.demoUrl" />

    <!-- 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-linking-for-pseo)

Internal links transform a collection of generated pages into a connected topic cluster that Google understands as authoritative. Without internal links, your programmatic pages are orphans, and

.
### [Link Topology](#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 ensures Google can crawl from any entry point to every other page in the cluster.

### [Automated Related Links](#automated-related-links)

Instead of hardcoding related pages, generate them based on shared tags or categories:

server/routes/features/[slug].ts

```
import { features } from '../data/features'

// In your Express/h3 route handler:
export function getFeatureWithRelated(slug: string) {
  const feature = features.find(f => f.slug === slug)

  if (!feature)
    return null

  // Auto-generate related features based on shared tags
  const related = features
    .filter(f => f.slug !== slug)
    .filter(f => f.tags.some((tag: string) => feature.tags.includes(tag)))
    .slice(0, 4)
    .map(f => ({ slug: f.slug, title: f.title, excerpt: f.excerpt }))

  return { ...feature, relatedFeatures: related }
}
```

This approach scales automatically. When you add a new feature with the tag "design," it immediately appears as a related feature on all other design-tagged pages.

## [Common Mistakes](#common-mistakes)

### [Generating Thousands of Near-Identical Pages](#generating-thousands-of-near-identical-pages)

The most damaging pSEO mistake. Generating 5,000 pages where 4,900 have the same paragraph with a different city name ("Best plumber in {city}") will trigger Google's thin content filters and hurt your entire domain. [Sites hit by the March 2024 update](https://www.searchenginejournal.com/google-manual-actions-march-2024/510651/) saw this pattern consistently.

Start with a smaller number of high-quality pages and expand only when each new page has genuinely unique content.

### [Missing Canonical Tags on URL Variations](#missing-canonical-tags-on-url-variations)

If your comparison pages are accessible at both `/compare/figma-vs-sketch` and `/compare/sketch-vs-figma`, Google sees duplicate content. Set a canonical URL that normalizes the order:

src/pages/compare/[slug].vue

```
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const slug = route.params.slug as string

// Normalize: always alphabetical order in the canonical
const normalizedSlug = computed(() => {
  const parts = slug.split('-vs-')
  if (parts.length === 2) {
    return parts.sort().join('-vs-')
  }
  return slug
})

useHead({
  link: [
    { rel: 'canonical', href: \`https://yoursite.com/compare/${normalizedSlug.value}\` }
  ]
})
</script>
```

Better yet, redirect the non-canonical variation to the canonical URL in a Vue Router navigation guard so only one version exists.

### [Not Submitting Generated URLs to Sitemap](#not-submitting-generated-urls-to-sitemap)

Programmatic pages that exist only in your dynamic routes are invisible to Google until something links to them. Always register them in your sitemap using `vite-plugin-sitemap` as shown in the [sitemap generation section](#generating-routes-for-sitemap) above, and make sure your hub pages link to every generated page.

### [No Internal Links Between Generated Pages](#no-internal-links-between-generated-pages)

Generating 50 feature pages without any cross-links between them wastes the primary advantage of pSEO: topical authority through interconnection. Every feature page should link to related features using `<RouterLink>`, every comparison page should link to the features it discusses, and hub pages should link to everything.

### [Keyword-Stuffed URLs](#keyword-stuffed-urls)

Keep slugs clean and readable. One or two keywords in the slug is enough.

✅ Correct

❌ Wrong

```
/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-extension
```

Long, keyword-stuffed URLs look spammy to both users and search engines. They get truncated in search results, reducing click-through rates. Read more about

.

---

On this page

- [What Programmatic SEO Requires](#what-programmatic-seo-requires)
- [The Quality Line After Google's March 2024 Update](#the-quality-line-after-googles-march-2024-update)
- [Page Types That Work](#page-types-that-work)
- [Vue Implementation](#vue-implementation)
- [Generating Routes for Sitemap](#generating-routes-for-sitemap)
- [Managing Crawl Budget at Scale](#managing-crawl-budget-at-scale)
- [The Zero-Click and AI Search Factor](#the-zero-click-and-ai-search-factor)
- [Avoiding Thin Content](#avoiding-thin-content)
- [Internal Linking for pSEO](#internal-linking-for-pseo)
- [Common Mistakes](#common-mistakes)

[GitHub](https://github.com/harlan-zw/nuxt-seo) [ Discord](https://discord.com/invite/275MBUBvgP)

###

-
-

Modules

-
-
-
-
-
-
-
-
-

###

-
-
-

###

Nuxt

-
-
-
-
-

Vue

-
-
-
-
-
-
-
-

###

-
-
-
-
-
-
-
-
-
-

Copyright © 2023-2026 Harlan Wilton - [MIT License](https://github.com/harlan-zw/nuxt-seo/blob/main/license) · [mdream](https://mdream.dev)