Build a Comparison Page in Astro: Side-by-Side Content with Structured Schema

How to create a comparison page in Astro with Content Collections — schema design for two-product comparisons, winner display, verdict box, and a comparison table built with plain CSS.

Quick answer

How do you build a comparison page in Astro?

Create a compares Content Collection with a schema that includes itemA, itemB, winner, and verdict fields. Build a dynamic route at src/pages/compares/[slug].astro that renders the comparison with a VS display, winner badge, and verdict box.

Browser showing an Astro comparison page with VS box, winner badge, verdict section and content
First-hand experience: Based on direct hands-on use. The Astro vs Next.js and WordPress vs Astro comparison pages on astro-content-lab.vercel.app were built exactly this way.

Comparison content is one of the highest-value content types for affiliate and review sites. “X vs Y” searches have high intent — the reader has already narrowed their choice to two options and wants help deciding.

WordPress handles this with plugins — Comparison Table, TablePress, or custom ACF setups. Some themes have built-in comparison features. Most of them look similar: a table with checkmarks.

In Astro, you define exactly what a comparison is, how it’s stored, and how it renders — with no plugin dependency and no feature lock-in.

This article builds a complete comparison content type from schema to page.


How comparison content differs from reviews

A review answers: “Is this product good?”

A comparison answers: “Which of these two products is better for my situation?”

Different questions, different data shapes:

Review schema:

productName, rating, pros[], cons[], verdict

Comparison schema:

itemA, itemB, winner, verdict

A comparison has two subjects instead of one. The “rating” becomes a “winner” — one product wins, or it depends on use case. The pros/cons become a side-by-side evaluation.

Same Content Collections pattern. Different schema, different template.


Step 1: Add the compares collection

Open src/content.config.ts and add:

import { defineCollection, z } from 'astro:content'
import { glob } from 'astro/loaders'

const posts = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/posts' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    publishedAt: z.date(),
  }),
})

const reviews = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/reviews' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    productName: z.string(),
    rating: z.number(),
    pros: z.array(z.string()),
    cons: z.array(z.string()),
    verdict: z.string(),
    publishedAt: z.date(),
  }),
})

const compares = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/compares' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    itemA: z.string(),
    itemB: z.string(),
    winner: z.string(),
    verdict: z.string(),
    publishedAt: z.date(),
  }),
})

export const collections = {
  posts,
  reviews,
  compares,
}

The key fields:

  • itemA and itemB — the two products being compared
  • winner — which product wins (or “Depends” for context-specific answers)
  • verdict — one sentence summary of the decision

Step 2: Create the content folder and first comparison

mkdir src/content/compares
touch src/content/compares/astro-vs-nextjs.md

Open src/content/compares/astro-vs-nextjs.md:

---
title: Astro vs Next.js
description: Both are modern JavaScript frameworks but built for different purposes. Here is how to choose between them for your next project.
itemA: Astro
itemB: Next.js
winner: Depends
verdict: Astro wins for content-heavy static sites. Next.js wins for web applications and dynamic experiences.
publishedAt: 2026-06-17T00:00:00Z
---

## Astro vs Next.js

Both are modern JavaScript frameworks. Both are popular. Both are well-maintained.

They are built for fundamentally different purposes.

**Astro** is designed for content sites. It pre-renders everything to static HTML by default. Zero JavaScript shipped to the browser unless you explicitly add it.

**Next.js** is designed for web applications. It is React-based and supports multiple rendering modes. Built for sites that need dynamic data, user authentication, and complex interactivity.

## Performance

For static content, Astro wins clearly. Astro ships zero JavaScript by default. A blog post rendered by Astro is pure HTML — no React runtime, no hydration overhead.

Next.js ships the React runtime on every page, even static ones. For a content page with no interactivity, that is unnecessary weight.

## When to choose Astro

- Blog, review site, affiliate site, documentation
- Performance and Core Web Vitals are a priority
- You control all content updates yourself

## When to choose Next.js

- Web application with user accounts
- Dashboard with real-time data
- When your team already knows React

## My verdict

Match the tool to the job. Using Next.js for a blog because it is more famous is the wrong decision. Using Astro for a complex application because it is simpler to learn is equally wrong.

Step 3: Create the comparisons listing page

Create src/pages/compares.astro:

---
import { getCollection } from 'astro:content'
import BaseLayout from '../layouts/BaseLayout.astro'

const compares = await getCollection('compares')
---

<BaseLayout title="Comparisons — Astro Content Lab">
  <div class="page-header">
    <h1>Comparisons</h1>
    <p>Side-by-side comparisons to help you pick the right tool.</p>
  </div>
  <div class="post-list">
    {compares.map((compare) => (
      <a href={`/compares/${compare.id}`} class="post-item">
        <div class="post-item-content">
          <span class="badge">Compare</span>
          <h2>{compare.data.title}</h2>
          <p>{compare.data.description}</p>
          <p class="winner">🏆 Winner: {compare.data.winner}</p>
        </div>
        <span class="post-item-link">Read comparison →</span>
      </a>
    ))}
  </div>
</BaseLayout>

<style>
  .page-header {
    margin-bottom: 2.5rem;
    padding-bottom: 1.5rem;
    border-bottom: 1px solid var(--color-border);
  }

  .page-header h1 { margin-bottom: 0.5rem; }
  .page-header p { color: var(--color-text-muted); margin: 0; }

  .post-list {
    display: flex;
    flex-direction: column;
    gap: 1rem;
  }

  .post-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 1.25rem 1.5rem;
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    text-decoration: none;
    color: var(--color-text);
    transition: all 0.2s;
    gap: 1rem;
  }

  .post-item:hover {
    border-color: var(--color-primary);
    box-shadow: var(--shadow-sm);
    text-decoration: none;
  }

  .post-item-content h2 {
    font-size: 1.0625rem;
    margin: 0.35rem 0;
  }

  .post-item-content p {
    font-size: 0.875rem;
    color: var(--color-text-muted);
    margin: 0;
  }

  .post-item-link {
    font-size: 0.875rem;
    color: var(--color-primary);
    font-weight: 500;
    white-space: nowrap;
    flex-shrink: 0;
  }

  .badge {
    display: inline-block;
    font-size: 0.75rem;
    font-weight: 600;
    padding: 0.2rem 0.6rem;
    border-radius: 999px;
    background: #dbeafe;
    color: #1d4ed8;
  }

  .winner {
    margin-top: 0.35rem !important;
  }

  @media (max-width: 640px) {
    .post-item { flex-direction: column; align-items: flex-start; }
  }
</style>

Step 4: Create the comparison detail page

Create src/pages/compares/[slug].astro via VS Code:

---
import { getCollection, render } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'

export async function getStaticPaths() {
  const compares = await getCollection('compares')
  return compares.map((compare) => ({
    params: { slug: compare.id },
    props: { compare },
  }))
}

const { compare } = Astro.props
const { Content } = await render(compare)

const publishedDate = new Date(compare.data.publishedAt).toLocaleDateString('en-US', {
  year: 'numeric', month: 'long', day: 'numeric'
})
---

<BaseLayout title={`${compare.data.title} — Astro Content Lab`}>
  <article class="post">

    <div class="post-header">
      <a href="/compares" class="back-link">← Back to Comparisons</a>
      <span class="badge">Compare</span>
      <h1>{compare.data.title}</h1>
      <p class="post-desc">{compare.data.description}</p>
      <p class="post-meta">Published {publishedDate}</p>
    </div>

    <div class="compare-vs">
      <div class="compare-item">
        <h3>{compare.data.itemA}</h3>
      </div>
      <div class="vs-badge">VS</div>
      <div class="compare-item">
        <h3>{compare.data.itemB}</h3>
      </div>
    </div>

    <div class="winner-box">
      🏆 <strong>Winner:</strong> {compare.data.winner}
    </div>

    <div class="verdict-box">
      <strong>Verdict:</strong> {compare.data.verdict}
    </div>

    <div class="post-body">
      <Content />
    </div>

  </article>
</BaseLayout>

<style>
  .post {
    max-width: 680px;
    margin: 0 auto;
  }

  .back-link {
    display: inline-block;
    font-size: 0.875rem;
    color: var(--color-text-muted);
    text-decoration: none;
    margin-bottom: 1rem;
  }

  .back-link:hover { color: var(--color-primary); }

  .badge {
    display: inline-block;
    font-size: 0.75rem;
    font-weight: 600;
    padding: 0.2rem 0.6rem;
    border-radius: 999px;
    background: #dbeafe;
    color: #1d4ed8;
    margin-bottom: 0.75rem;
  }

  .post-header {
    margin-bottom: 2rem;
    padding-bottom: 1.5rem;
    border-bottom: 1px solid var(--color-border);
  }

  .post-header h1 { margin-bottom: 0.75rem; }

  .post-desc {
    font-size: 1.125rem;
    color: var(--color-text-muted);
    margin-bottom: 0.5rem;
  }

  .post-meta {
    font-size: 0.875rem;
    color: var(--color-text-muted);
    margin: 0;
  }

  .compare-vs {
    display: flex;
    align-items: center;
    gap: 1rem;
    margin-bottom: 1.5rem;
    padding: 1.5rem;
    background: var(--color-bg-soft);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    text-align: center;
  }

  .compare-item {
    flex: 1;
  }

  .compare-item h3 {
    margin: 0;
    font-size: 1.125rem;
  }

  .vs-badge {
    font-weight: 700;
    font-size: 0.875rem;
    color: var(--color-text-muted);
    background: var(--color-border);
    padding: 0.35rem 0.75rem;
    border-radius: 999px;
    flex-shrink: 0;
  }

  .winner-box {
    padding: 1rem 1.25rem;
    background: #fefce8;
    border: 1px solid #fde68a;
    border-radius: var(--radius);
    margin-bottom: 1rem;
    font-size: 0.9375rem;
  }

  .verdict-box {
    padding: 1rem 1.25rem;
    background: #f0fdf4;
    border: 1px solid #bbf7d0;
    border-radius: var(--radius);
    margin-bottom: 2rem;
    font-size: 0.9375rem;
  }

  .post-body { line-height: 1.8; }
</style>

The result

The VS display shows both products side by side. The winner box highlights the winning product in yellow. The verdict box summarizes the decision in green. Everything comes directly from the frontmatter — no hardcoded values in the template.


What a proper ComparisonTable looks like

The current comparison layout is functional but basic. The VS box and winner/verdict boxes communicate the key information — but a real comparison site benefits from a proper comparison table:

| Feature          | Astro          | Next.js         |
|------------------|----------------|-----------------|
| Default JS       | Zero           | React runtime   |
| Best for         | Content sites  | Web apps        |
| Learning curve   | Gentle         | Steeper         |
| E-commerce       | Limited        | Full React eco  |

To support this properly, the schema needs a comparisonRows field:

comparisonRows: z.array(z.object({
  label: z.string(),
  left: z.string(),
  right: z.string(),
  winner: z.enum(['left', 'right', 'tie', 'depends']).optional(),
})).optional(),

And the template needs a ComparisonTable component that renders each row with winner highlighting.

That component is built in the component library article — along with the Tailwind styling that makes the table look like a proper comparison site, not a formatted blog post.

For now, the Markdown body can contain a plain Markdown table that renders via the table CSS in global.css. It’s not as structured as a proper component, but it communicates the comparison clearly.


Extending the schema

The current schema is intentionally minimal. As your comparison site grows, you might add:

Category field:

category: z.string().optional(),

Published/draft status:

status: z.enum(['draft', 'published']).default('published'),

Last updated date:

updatedAt: z.date().optional(),

Comparison rows for the table:

comparisonRows: z.array(z.object({
  label: z.string(),
  left: z.string(),
  right: z.string(),
})).optional(),

Add fields when you need them. The schema is TypeScript — change it, and every template that uses compare.data gets autocomplete for the new fields immediately.


Frequently Asked Questions

What fields should a comparison page schema have?
At minimum: title, description, itemA, itemB, winner, verdict, and publishedAt. For richer comparisons, add comparisonRows as an optional array of objects with label, left, and right fields to support a proper data-driven comparison table.
How is a comparison page different from a review page?
A review covers one product with rating, pros, and cons. A comparison covers two products side by side with a winner declaration. They use separate collections with different schemas, separate dynamic routes, and separate layouts. The getStaticPaths pattern is identical — only the data shape and template change.
How do you show a winner in a comparison?
Add a winner field as z.string() to the schema. In frontmatter, set it to the winning product name or 'Depends' for context-dependent answers. Display it in the template with a highlighted box. For stricter control, use z.enum(['left', 'right', 'depends']) and map to product names in the template.
Can I add a proper comparison table with rows?
Yes. Add comparisonRows as z.array(z.object({...})) to the schema and define rows in the frontmatter. The component library article later in this series builds a ComparisonTable component with Tailwind that renders rows with winner highlighting — the kind of table you see on serious comparison sites.