Build a Review Page in Astro: Schema, Layout and Components

How to create a structured review page in Astro with Content Collections — schema design, pros/cons display, rating stars, and a verdict box. With plain CSS and a promise of what's coming.

Quick answer

How do you build a review page in Astro?

Create a reviews Content Collection with a Zod schema that includes productName, rating, pros, cons, and verdict fields. Build a dynamic route at src/pages/reviews/[slug].astro that fetches the collection and renders each review with a structured layout.

Browser showing an Astro review page with rating stars, pros and cons boxes, and verdict section
First-hand experience: Based on direct hands-on use. The Hostinger Review and Vercel Review on astro-content-lab.vercel.app were built exactly this way. The source code is on GitHub.

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:

  • posts collection → title, description, publishedAt
  • reviews collection → everything above plus productName, 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 like 4.5
  • pros: z.array(z.string()) — must be a list of strings
  • cons: z.array(z.string()) — same
  • verdict: 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.


Frequently Asked Questions

Why use Content Collections for reviews instead of regular Markdown?
Reviews have specific fields that every entry must have — rating, pros, cons, verdict. Content Collections with Zod schema enforce that every review file has all required fields with the correct data types. Missing a rating or using the wrong type throws an error at build time, not in production.
How do you display star ratings in Astro without a plugin?
Use JavaScript string repeat: '★'.repeat(Math.floor(rating)) creates filled stars, '☆'.repeat(5 - Math.floor(rating)) creates empty stars. This works for whole and rounded ratings. For precise half-star support and full accessibility, the component library article later in this series covers a proper RatingStars component.
How do pros and cons lists work in the schema?
Define them as z.array(z.string()) in the Zod schema. In Markdown frontmatter, write them as YAML arrays with each item on a new line starting with a dash. In the template, use .map() to render each item as a list element. The schema ensures every review has both arrays.
Can I have different layouts for different content types?
Yes. Create separate dynamic route files — src/pages/blog/[slug].astro for posts, src/pages/reviews/[slug].astro for reviews. Each file has its own template, CSS, and accesses its own collection. Same getStaticPaths pattern, different data and layout.