A blog post is a blob of text with a title and a date. A review is different — it has specific fields that every review must have. A rating. A list of pros. A list of cons. A verdict.
If you’ve built affiliate or review sites on WordPress, you’ve solved this with a plugin — WP Review, WPSSO, or a custom ACF setup. The plugin gives you the fields, you fill them in, it renders them.
In Astro, you define the fields yourself in a Content Collection schema. No plugin. No registration. Just a TypeScript definition and a template that uses it.
This article builds a complete review system from schema to rendered page.
Why reviews need their own collection
You could put reviews in the same posts collection as blog posts. But then every post would need rating, pros, cons, and verdict fields — fields that only make sense for reviews.
Content Collections let you define separate schemas for separate content types:
postscollection →title,description,publishedAtreviewscollection → everything above plusproductName,rating,pros,cons,verdict
This is exactly how WordPress Custom Post Types work — a Review CPT has different fields than a Post CPT. The difference is that Astro enforces the schema at build time with TypeScript validation.
Step 1: Define the reviews collection
Open src/content.config.ts and add the reviews collection:
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(),
}),
})
export const collections = {
posts,
reviews,
}
The reviews schema has more fields than posts. Each field has a specific type:
rating: z.number()— must be a number like4.5pros: z.array(z.string())— must be a list of stringscons: z.array(z.string())— sameverdict: z.string()— a single text verdict
If a review file has rating: "very good" instead of rating: 4.5, Astro throws an error at build time. The schema is your contract — content must honor it.
Step 2: Create the reviews folder and first review
mkdir src/content/reviews
touch src/content/reviews/hostinger-review.md
Open src/content/reviews/hostinger-review.md:
---
title: Hostinger Review 2026
description: Is Hostinger worth it for WordPress hosting? After using it for several projects, here is my honest take.
productName: Hostinger
rating: 4.2
pros:
- Genuinely cheap — promotional pricing starts under $3/mo
- Fast setup — WordPress installs in under 2 minutes
- hPanel is clean and beginner-friendly
- Good performance for the price tier
cons:
- Renewal price is significantly higher than intro price
- No monthly billing on cheaper plans
- Support quality is inconsistent
verdict: Good starting point for beginners. Not a long-term home for serious projects.
publishedAt: 2026-06-17T00:00:00Z
---
## Hostinger Review
Hostinger is everywhere. If you've been researching web hosting, you've seen the name.
The pricing is hard to ignore. Under $3/month sounds almost too cheap to be real.
After using Hostinger for WordPress projects, here is what I actually think.
## What Hostinger does well
Setup speed is genuinely good. New account to live WordPress site in under 5 minutes.
Performance for the price tier is better than most shared hosts at this price point.
## The renewal pricing reality
The advertised price — say $2.99/mo — is for a 48-month plan paid upfront. When that plan renews, the rate jumps to around $7.99-9.99/mo.
Always calculate the actual cost before committing.
## My verdict
Hostinger is a legitimate option for beginners. The setup is easy, the price is low, and the performance is adequate for small sites.
Notice the pros and cons fields — YAML arrays with each item on its own line starting with -. This maps directly to z.array(z.string()) in the schema.
Step 3: Create the reviews listing page
Create src/pages/reviews.astro:
---
import { getCollection } from 'astro:content'
import BaseLayout from '../layouts/BaseLayout.astro'
const reviews = await getCollection('reviews')
---
<BaseLayout title="Reviews — Astro Content Lab">
<div class="page-header">
<h1>Reviews</h1>
<p>Honest reviews of hosting, tools and services for content sites.</p>
</div>
<div class="post-list">
{reviews.map((review) => (
<a href={`/reviews/${review.id}`} class="post-item">
<div class="post-item-content">
<span class="badge">Review</span>
<h2>{review.data.title}</h2>
<p>{review.data.description}</p>
<p class="rating">
{'★'.repeat(Math.floor(review.data.rating))}
{'☆'.repeat(5 - Math.floor(review.data.rating))}
{' '}{review.data.rating}/5
</p>
</div>
<span class="post-item-link">Read review →</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: #ede9fe;
color: #7c3aed;
}
.rating {
color: var(--color-warning);
margin-top: 0.35rem !important;
}
@media (max-width: 640px) {
.post-item { flex-direction: column; align-items: flex-start; }
}
</style>
The rating display uses a simple JavaScript trick:
'★'.repeat(Math.floor(4.2)) // → ★★★★
'☆'.repeat(5 - Math.floor(4.2)) // → ☆
Math.floor(4.2) rounds down to 4. Four filled stars, one empty star. Simple, no library needed.
Step 4: Create the review detail page
Create the folder and file:
mkdir src/pages/reviews
touch src/pages/reviews/[slug].astro # create via VS Code — terminal doesn't handle brackets
Open src/pages/reviews/[slug].astro:
---
import { getCollection, render } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'
export async function getStaticPaths() {
const reviews = await getCollection('reviews')
return reviews.map((review) => ({
params: { slug: review.id },
props: { review },
}))
}
const { review } = Astro.props
const { Content } = await render(review)
const publishedDate = new Date(review.data.publishedAt).toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric'
})
const stars = '★'.repeat(Math.floor(review.data.rating)) +
'☆'.repeat(5 - Math.floor(review.data.rating))
---
<BaseLayout title={`${review.data.title} — Astro Content Lab`}>
<article class="post">
<div class="post-header">
<a href="/reviews" class="back-link">← Back to Reviews</a>
<span class="badge">Review</span>
<h1>{review.data.title}</h1>
<p class="post-desc">{review.data.description}</p>
<p class="rating">{stars} {review.data.rating}/5</p>
<p class="post-meta">Published {publishedDate}</p>
</div>
<div class="review-meta">
<div class="pros">
<h3>✅ Pros</h3>
<ul>
{review.data.pros.map((pro) => <li>{pro}</li>)}
</ul>
</div>
<div class="cons">
<h3>❌ Cons</h3>
<ul>
{review.data.cons.map((con) => <li>{con}</li>)}
</ul>
</div>
</div>
<div class="verdict">
<strong>Verdict:</strong> {review.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: #ede9fe;
color: #7c3aed;
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;
}
.rating {
color: var(--color-warning);
font-size: 1.125rem;
margin-bottom: 0.5rem;
}
.post-meta {
font-size: 0.875rem;
color: var(--color-text-muted);
margin: 0;
}
.review-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
padding: 1.5rem;
background: var(--color-bg-soft);
border: 1px solid var(--color-border);
border-radius: var(--radius);
}
.review-meta h3 {
font-size: 0.9375rem;
margin-top: 0;
margin-bottom: 0.75rem;
}
.review-meta ul {
margin: 0;
padding-left: 1.25rem;
}
.review-meta li {
font-size: 0.875rem;
margin-bottom: 0.35rem;
}
.verdict {
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; }
@media (max-width: 640px) {
.review-meta { grid-template-columns: 1fr; }
}
</style>
What this looks like now
It works. The rating displays. The pros and cons render from the array. The verdict box stands out with a green background.
But let’s be honest — this is the foundation, not the finish line.
A note on what’s coming
The current review page uses plain CSS and basic HTML structure. It works — but it’s intentionally simple at this stage of the series.
In upcoming articles, this same review page will be upgraded with:
- Tailwind CSS — faster, more consistent styling
- RatingStars component — proper star display with half-star precision
- ProsConsBox component — styled pros/cons grid that looks like a real review widget
- VerdictBox component — verdict with score badge and color coding
- AffiliateCTA component — contextual product recommendation with affiliate link
The final result will look like a professional review theme — the kind that sells for $59 on ThemeForest. But built with code you own and understand completely.
That’s the point of building it yourself.
The schema is the most important part
Everything else — the layout, the CSS, the components — can be changed. The schema defines what your content is.
A review without a rating field isn’t a review — it’s a blog post with a misleading URL.
schema: z.object({
title: z.string(),
description: z.string(),
productName: z.string(), // ← what are we reviewing?
rating: z.number(), // ← how good is it?
pros: z.array(z.string()), // ← what's good about it?
cons: z.array(z.string()), // ← what's not?
verdict: z.string(), // ← bottom line?
publishedAt: z.date(),
})
Get the schema right and the rest follows. Change the CSS ten times — the schema stays.