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:
itemAanditemB— the two products being comparedwinner— 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.