Every product comparison or review site needs the same backend scaffolding: product models, category taxonomy, user-submitted reviews, rating aggregation, and a moderation queue. Most teams rebuild these pieces from scratch. SaaS review widgets offer shortcuts, but they lock you into their frontend and pricing tiers, which becomes a problem when you need full control over the data model and the UI.
This tutorial walks you through a working comparison and review site using Strapi 5 with Next.js 16 (App Router) and SQLite for local development. End users can browse products, view side-by-side comparisons, and submit reviews that go through moderation before publishing. By the end, you'll have four user-facing routes, four backend content types, and a moderation pipeline that requires zero custom middleware.
In brief:
- Model products, categories, reviews, and specifications in Strapi 5 using the Content-Type Builder and repeatable components
- Configure role-based permissions so authenticated users can submit reviews while public users can only read published content
- Build a comparison view that fetches multiple products in a single batched request and renders a spec-diff table
- Wire up Draft and Publish as a zero-config moderation pipeline for user-submitted reviews
What You'll Build
The frontend has four routes. The backend has four content types. Here's the breakdown:
Next.js Routes
/products: paginated listing with category filter/products/[slug]: product detail page with reviews/compare?slugs=...: side-by-side comparison of two to four products/reviews/new/[productSlug]: authenticated review submission form
Strapi Content Types
Product(collection): the core entity with specs, images, and pricingCategory(collection): used for filtering and groupingReview(collection, with Draft and Publish enabled): user-submitted ratings that require editorial approval- Product content can include structured fields used to support comparison-table content.
The Content-Type Builder handles all schema creation through the Admin Panel. No hand-written schema files needed for this tutorial.
Prerequisites
You need Node.js v20, v22, or v24 (Active LTS or Maintenance LTS only; Strapi 5 does not support odd-numbered releases like v23 or v25), npm/pnpm/yarn, working knowledge of the Next.js App Router and React Server Components, and a code editor.
Step 1: Scaffold the Strapi Backend
Strapi's project scaffolding command creates the backend directory, installs dependencies, and sets up a default SQLite database for local development. Run the installer:
npx create-strapi@latest reviews-backendUse the Strapi project creation command with the appropriate flags for your setup, such as --typescript if you want a TypeScript project and the default local database options for local development. For a deeper look at CLI usage, see the Strapi CLI guide.After the installer finishes, start the development server:
cd reviews-backend && npm run developThis boots the Admin Panel at http://localhost:1337/admin. Visit that URL in your browser and confirm the admin registration screen loads. Create your first administrator account when prompted. The Admin Panel is where all the content modeling in the next step happens.
Step 2: Model the Data
Content modeling defines the project. There are four schema pieces, each with a specific role.
Category
Create a new Collection Type called Category with the following two fields:
name(Text, short text)slug(UID, target field:name)
Categories are the filter facets on the product listing page and group products in the comparison view. Save the Content-Type and confirm it appears in the left sidebar under Collection Types. Add two or three test categories (e.g., Headphones, Laptops, Monitors) through the Content Manager so the Product relation has entries to connect to in the next step.
Product
Create a Collection Type called Product. Walk through each field:
name(Text, short text, required)slug(UID, target field:name)summary(Text, long text): a one-liner for listing cardsdescription(Rich text / Blocks): the full product writeupcoverImage(Media, single image)gallery(Media, multiple images)price(Decimal)manufacturer(Text, short text)category(Relation: Product has one Category; Category has many Products). See the Strapi docs on the relation field for configuration details.specifications(Component, repeatable, created in the next subsection)averageRating(Decimal): computed by a lifecycle hook in Step 8
The Blocks field for the description field is the Strapi 5 replacement for the older Markdown and Draftjs rich text fields. It outputs structured JSON rather than raw HTML. On the Next.js side, you will need a block renderer component to display it.
The @strapi/blocks-react-renderer package handles this, or you can write a custom renderer that maps each block type (paragraph, heading, image, list) to your own React components.
The slug field is what Next.js routes resolve against, not the numeric id. One important Strapi 5 detail: every API response now includes a documentId (an alphanumeric string like hgv1vny5cebq2l3czil1rpb3) alongside the integer id. Use documentId for all API operations going forward. The integer id exists for backward compatibility in Strapi 5 and should generally be ignored in favor of documentId.
Specification Component
In the Content-Type Builder sidebar, go to Components and create a new component in a category called product. Name it Specification with two fields:
label(Text, short text, required): e.g.,Battery lifevalue(Text, short text, required): e.g.,12 hours
Add this to the Product Content-Type as a repeatable component. This repeatable structure is what powers the comparison table later. A repeatable component is the right call over a raw JSON field because Strapi's Admin Panel presents repeatable components as editable items, though validation behavior for each nested entry has had limitations in some cases. For more on this pattern, see components docs in the Strapi documentation.
Review
Create a Collection Type called Review with these fields:
title(Text, short text)body(Text, long text)rating(Integer, min 1, max 5)product(Relation: Review has one Product; Product has many Reviews)author(Relation: Review has one User fromusers-permissions; User has many Reviews)verifiedPurchase(Boolean, default false)
In the Advanced Settings tab during creation, enable Draft & Publish. This is the moderation pipeline: content is created as drafts, and an editor publishes it after review. No plugin required. The review pattern covers a similar approach (note: that article references Strapi v4, but the Draft and Publish concept carries over).
After saving all four content types, the sidebar should list Category, Product, and Review under Collection Types. The Specification component appears under Components in the Content-Type Builder.
Step 3: Configure Permissions
Role-based permissions control who can read, create, and modify content through the REST API. Navigate to Settings → Users and Permissions plugin → Roles in the Admin Panel.
Public Role
Click Public and enable the following actions:
Product:find,findOneCategory:find,findOneReview:find,findOneonly
Public users can browse products and read approved reviews, but they cannot submit anything.
Authenticated Role
Click Authenticated. The Authenticated role inherits the same read access. Additionally, enable create on Review. Do not enable update or delete on Review for this role. Those operations are reserved for moderators working in the Admin Panel.
Here's why this works without extra configuration: the REST API returns only published entries by default. Passing no status parameter is equivalent to status=published.
New review submissions are typically created as drafts with publishedAt: null and remain invisible to the Public role until an editor publishes them. For more on configuring access, see RBAC guide and the Users & Permissions feature page.
Test permissions by opening http://localhost:1337/api/products in a browser with no auth header. You should see an empty data array (or populated data if you added test entries). Hitting /api/reviews should also return only published reviews.
Step 4: Set Up the Next.js Frontend
The Next.js frontend consumes the Strapi REST API and renders four routes: a product listing, a product detail page, a comparison view, and a review submission form.
Bootstrap the Project
Create the Next.js project with the App Router and Tailwind CSS:
npx create-next-app@latest reviews-frontend --typescript --app --tailwindPick the App Router when prompted. Then create an .env.local file with your Strapi connection details:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=<your-read-only-token>Generate the API token from Settings → API Tokens in the Strapi Admin Panel. Set the token type to Read-only for now. You'll use a separate user JWT for authenticated write operations later.
Build a Typed Fetch Helper
A shared fetch helper keeps Strapi API calls consistent across every route. Install the qs library for query string serialization:
npm install qs @types/qsCreate lib/strapi.ts with a generic function that handles query serialization, authorization headers, and Next.js caching options:
import qs from 'qs';
const baseUrl = process.env.NEXT_PUBLIC_STRAPI_URL;
const token = process.env.STRAPI_API_TOKEN;
export async function fetchAPI<T>(
path: string,
params: Record<string, unknown> = {},
opts: { revalidate?: number; tags?: string[] } = {}
): Promise<{ data: T; meta: Record<string, unknown> }> {
const query = qs.stringify(params, { encodeValuesOnly: true });
const url = `${baseUrl}/api${path}?${query}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
next: { revalidate: opts.revalidate ?? 60, tags: opts.tags },
});
if (!res.ok) throw new Error(`Strapi error: ${res.status}`);
return res.json();
}One critical detail: Strapi 5 returns a flat response shape. Each entry has documentId and all fields at the top level. There is no attributes wrapper anymore. If you're adapting code from older tutorials, drop every .attributes access. Your TypeScript interfaces should reflect the flat shape directly. For more on this approach, see the guide on building a type-safe fetch.
Define interfaces that mirror this flat shape. Every field sits at the top level of each object, with id and documentId both present:
// Types reflecting Strapi 5's flat response shape
interface Product {
id: number;
documentId: string;
name: string;
slug: string;
summary: string;
price: number;
manufacturer: string;
averageRating: number;
coverImage: StrapiMedia;
specifications: Specification[];
reviews?: Review[];
category?: Category;
}
interface Specification {
id: number;
label: string;
value: string;
}
interface Review {
id: number;
documentId: string;
title: string;
body: string;
rating: number;
verifiedPurchase: boolean;
author?: { username: string };
createdAt: string;
}
interface Category {
id: number;
documentId: string;
name: string;
slug: string;
}
interface StrapiMedia {
url: string;
width: number;
height: number;
alternativeText: string | null;
}These types feed into the generic parameter of fetchAPI<Product[]> used in later steps, giving you autocomplete and compile-time checks on every Strapi response.
Step 5: Build the Product Detail Page with Reviews
The product detail page resolves a slug from the URL, fetches the matching product with its reviews and specifications, and renders everything in a single Server Component.
The Route
Create app/products/[slug]/page.tsx. Next.js 16 params are a Promise, so type them accordingly and await before destructuring:
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// fetch and render
}Add generateStaticParams to prerender popular product pages at build time. Query Strapi for all product slugs and return them as an array of { slug } objects. Pages not included in this array still render on first request through ISR, then cache for subsequent visitors.
Fetch the Product with Reviews
Query Strapi by slug with explicit population. Avoid populate=* in production routes: it only goes one level deep, exposes fields you don't need, and hurts performance. Be specific and cap depth at two levels. See performance tips for additional production hygiene tips.
const { data } = await fetchAPI<Product[]>('/products', {
filters: { slug: { $eq: slug } },
populate: {
category: true,
specifications: true,
coverImage: true,
gallery: true,
reviews: {
populate: { author: { fields: ['username'] } },
sort: ['createdAt:desc'],
},
},
});
const product = data[0];The nested populate object form is required for anything beyond one level. Strapi 5 won't resolve deeper relations with the shorthand populate: '*' syntax.
Render the Page
The Server Component renders the product hero (image, name, manufacturer, price), the specifications table, an average-rating badge, and a ReviewList component that maps over the populated reviews.
Add JSON-LD structured data for better search engine results page (SERP) appearance. The following snippet builds a Product schema object with an aggregate rating:
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: product.averageRating,
reviewCount: product.reviews.length,
},
};Render it in a <script type="application/ld+json"> block. The structured data guide covers implementation patterns in more depth.
The ReviewList component maps over the populated reviews array, displaying the author username, a star rating rendered as filled and empty SVG icons (or Unicode ★ and ☆ characters), the review title, body text, and a "Verified Purchase" badge when that boolean is true.
Review ordering may need to be handled explicitly rather than assumed from the API query shown above. For empty states, render a message prompting visitors to be the first reviewer, with a link to /reviews/new/[productSlug].
Step 6: Build the Comparison View
The comparison view lets users select two to four products and see their specifications, prices, and ratings in a side-by-side table powered by a single batched API request.
The Route
Create app/compare/page.tsx. Next.js 16 searchParams are also async, so await them before reading values:
export default async function ComparePage({
searchParams,
}: {
searchParams: Promise<{ slugs?: string | string[] }>;
}) {
const { slugs } = await searchParams;
const selectedSlugs = Array.isArray(slugs) ? slugs : slugs ? [slugs] : [];
if (selectedSlugs.length < 2 || selectedSlugs.length > 4) {
return <p>Select between 2 and 4 products to compare.</p>;
}
const { data: products } = await fetchAPI<Product[]>('/products', {
filters: { slug: { $in: selectedSlugs } },
populate: { category: true, specifications: true, coverImage: true },
});
// render comparison table
}One request with a $in filter, not N separate fetches. This is a common place where homegrown comparison sites add unnecessary query overhead by looping through slugs instead of batching. For more on product data modeling, see product information manager.
Render the Comparison Table
Specifications are repeatable components with label and value. Different products may have different spec sets, so you need to derive the union of all unique labels across the compared products:
function unionLabels(products: Product[]) {
return Array.from(
new Set(products.flatMap((p) => p.specifications.map((s) => s.label)))
);
}Build a table with one column per product and one row per specification label. When a product is missing a particular spec, render - in that cell.
Add a price row and an average rating row. At the bottom, include a call-to-action (CTA) row linking to each product's detail page. A small touch that helps the table read faster is highlighting winning cells per row (lowest price, highest rating) with a Tailwind utility class like bg-green-50.
const labels = unionLabels(products);
<table>
<thead>
<tr>
<th />
{products.map((p) => (
<th key={p.documentId}>{p.name}</th>
))}
</tr>
</thead>
<tbody>
{labels.map((label) => (
<tr key={label}>
<td>{label}</td>
{products.map((p) => {
const spec = p.specifications.find((s) => s.label === label);
return <td key={p.documentId}>{spec?.value ?? '-'}</td>;
})}
</tr>
))}
</tbody>
</table>For the winning-cell highlight, handle the price and rating rows separately from string-based specification rows. In the price row, compute Math.min(...products.map(p => p.price)) and apply bg-green-50 to the cell whose product matches the minimum. In the rating row, use Math.max on averageRating instead.
String-based spec values (like Battery life: 12 hours) cannot be compared numerically, so skip highlighting for those rows. On the product listing page (/products), add an "Add to Compare" button on each product card.
When clicked, the button appends the product slug to the slugs query parameter using router.push or a plain <Link>. Store selected comparison slugs in URL state rather than React state so the comparison link is shareable, and so a page refresh does not lose the selection.
Step 7: Submit Reviews from the Frontend
Authenticated users submit reviews through a form that handles login, registration, and the review POST request. This step covers the full flow from credential exchange to draft creation.
Authenticate the User
For a tutorial-scope auth flow, use Strapi's built-in Users & Permissions endpoints. POST /api/auth/local/register creates an account. POST /api/auth/local returns a JSON Web Token (JWT). Note: the login body uses identifier (not email) as the field name, accepting either an email or username.
Store the returned JWT in an HTTP-only cookie via a Next.js Route Handler at app/api/auth/login/route.ts:
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const body = await request.json();
const strapiRes = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: body.identifier, password: body.password }),
});
if (!strapiRes.ok) return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
const data = await strapiRes.json();
const response = NextResponse.json({ user: data.user });
response.cookies.set('jwt', data.jwt, { httpOnly: true, secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 24 * 7 });
return response;
}The identifier field accepts either a username or an email address. The cookie's maxAge is set to seven days, after which the user will need to log in again. Production projects should layer this behind NextAuth or a similar library. The NextAuth.js guide covers that integration.
The registration endpoint at POST /api/auth/local/register expects username, email, and password in the request body. It returns the same { jwt, user } shape as the login endpoint.
Build the review submission form with both a login tab and a registration tab. After either action, the JWT cookie is set identically through the same Route Handler pattern, so the rest of the submission flow works the same regardless of which tab the user completed.
Submit the Review
Create a Server Action in app/reviews/new/[productSlug]/actions.ts that reads the JWT from the cookie and posts the review data to Strapi:
'use server';
import { cookies } from 'next/headers';
export async function submitReview(formData: FormData) {
const cookieStore = await cookies();
const jwt = cookieStore.get('jwt')?.value;
if (!jwt) throw new Error('Not authenticated');
const strapiUrl = process.env.NEXT_PUBLIC_STRAPI_URL;
const res = await fetch(`${strapiUrl}/api/reviews`, {
method: 'POST',
headers: {
Authorization: `Bearer ${jwt}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: {
title: formData.get('title'),
body: formData.get('body'),
rating: Number(formData.get('rating')),
product: formData.get('productDocumentId'),
},
}),
});
if (!res.ok) throw new Error('Review submission failed');
return res.json();
}Two details to stress here. First, send the documentId (the alphanumeric string) when referencing a related Product. For many-to-one relations in a creation payload, pass the documentId string directly as the field value.
The connect array syntax with { documentId: "..." } objects is available for more complex relation operations, but the short form is preferred here. Second, Draft and Publish means the response will have publishedAt: null. The review is invisible to the Public role until a moderator publishes it from the Admin Panel. See Strapi permissions for more on how permission layers interact.
Step 8: Cache, Revalidate, and Ship
This final step covers cache invalidation, computed fields, and database configuration for production readiness.
Revalidation. Tag your fetch calls (next: { tags: ['products'] }) and call revalidateTag('products', 'max') from a Next.js Route Handler triggered by a Strapi webhook.
In the Strapi Admin Panel, go to Settings → Webhooks → Create new webhook, point it at your Next.js endpoint (e.g., https://your-app.com/api/revalidate), and select the entry.publish and entry.unpublish events.
This is faster than time-based revalidation for an editorial workflow where content changes are infrequent but should appear immediately. Here is a minimal Route Handler that receives the webhook:
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST() {
revalidateTag('products', 'max');
return NextResponse.json({ revalidated: true });
}Next.js 16 changed revalidateTag to require a second argument specifying a cacheLife profile. 'max' keeps the existing stale-while-revalidate behavior; readers see the previous value until the new fetch resolves. If you need read-your-writes semantics from inside a Server Action instead, switch to updateTag so the change is visible on the same request.
Production deployments should validate the incoming request with a shared secret header (configured in the Strapi webhook settings) to prevent unauthorized cache busting.
Average rating computation. Add a lifecycle hook at src/api/review/content-types/review/lifecycles.ts that recomputes the parent Product's averageRating on afterCreate, afterUpdate, and afterDelete. Use strapi.documents('api::review.review') for all data access. The strapi.entityService API is deprecated in Strapi 5.
export default {
async afterCreate(event) {
const productDocumentId =
event.result?.product?.documentId ?? event.params?.data?.product;
if (productDocumentId) await recomputeRating(productDocumentId);
},
async afterUpdate(event) {
const productDocumentId =
event.result?.product?.documentId ?? event.params?.data?.product;
if (productDocumentId) await recomputeRating(productDocumentId);
},
async afterDelete(event) {
const productDocumentId = event.result?.product?.documentId;
if (productDocumentId) await recomputeRating(productDocumentId);
},
};
async function recomputeRating(productDocumentId: string) {
const reviews = await strapi
.documents('api::review.review')
.findMany({ filters: { product: { documentId: productDocumentId } } });
const avg =
reviews.length > 0
? reviews.reduce((sum, r) => sum + (r.rating ?? 0), 0) / reviews.length
: 0;
await strapi
.documents('api::product.product')
.update({ documentId: productDocumentId, data: { averageRating: avg } });
}Database. Swap SQLite for PostgreSQL before deploying. Configure config/database.ts with environment variables for DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USERNAME, and DATABASE_PASSWORD.
One gotcha: a new PostgreSQL user created specifically for Strapi needs SCHEMA permissions explicitly granted, or you'll hit a 500 error on the admin console. See SQLite limitations and deployment options for more on production readiness.
How Strapi Powers Your Comparison and Review Site
You now have a product comparison and review site with structured content modeling, role-based permissions, a Draft and Publish moderation pipeline, batched comparison queries, and webhook-driven cache revalidation. From here, add full-text search through a Meilisearch integration or enforce verified-purchase status with a custom policy on the Review.create route.
How Strapi powered this:
- The Content-Type Builder models products, categories, reviews, and specification components without a schema file;
- Draft and Publish provides a zero-config moderation pipeline for user-submitted reviews;
- REST API
$infilters batch multi-product comparison queries into a single request; - Role-based permissions separate public read access from authenticated write access;
- Webhook events trigger on-demand ISR so published content appears immediately;
- Lifecycle hooks recompute aggregate ratings without custom middleware.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.