Coupon sites live and die by freshness: content changes constantly, stale codes frustrate users, and every page needs to balance up-to-date data with fast load times. By the end of this tutorial, you'll have a coupons and deals site with a Strapi 5 backend modeling stores, deals, and categories, plus a Next.js 16 App Router frontend that lists deals, filters by category, and reveals coupon codes on a single-deal page.
The stack: Strapi 5 as the headless CMS for content modeling and the REST API, Next.js 16 with the App Router for rendering, and Incremental Static Regeneration (ISR) to keep pages fresh without full rebuilds. This tutorial skips authentication, admin user management, payments, and affiliate redirect tracking. Each of those is a natural follow-up once the core site is running.
In brief:
- Model stores, categories, and deals as related Collection Types in Strapi 5's Content-Type Builder.
- Fetch deal data with explicit
populateandfiltersusing Strapi's REST API and theqslibrary. - Build a deals listing, single-deal page with coupon reveal, and category filtering using Next.js 16 Server and Client Components.
- Ship with ISR at 60-second intervals and a clear path to webhook-triggered on-demand revalidation.
Prerequisites and Architecture
This section covers the tooling your machine needs and the request flow between the Strapi backend and the Next.js frontend.
What You Need Installed
To complete this tutorial, confirm you have the following tools and versions available on your machine:
- Node.js v20, v22, or v24 (odd-numbered releases like v23 or v25 are unsupported)
- npm or pnpm
- A code editor and a terminal
- Familiarity with the Next.js App Router and basic REST concepts
How the Two Apps Fit Together
Strapi runs on localhost:1337 and serves two roles: the Admin Panel (where you model content types and manage entries) and the REST API (where Next.js fetches data).
As the headless CMS, Strapi owns the data model and content. Next.js runs on localhost:3000, calls Strapi's /api/* endpoints at build time and on revalidation, then renders HTML for the browser. Next.js owns routing, rendering, and caching.
┌─────────────────┐ REST API ┌──────────────────┐
│ Strapi 5 │ ◄──────────────────────► │ Next.js 16 │
│ localhost:1337 │ /api/deals?populate=... │ localhost:3000 │
│ │ │ │
│ Admin Panel │ │ App Router │
│ Content Types │ │ Server Components│
│ Media Library │ │ ISR / revalidate│
└─────────────────┘ └──────────────────┘Set Up the Strapi Backend
The Strapi backend is a standalone Node.js application that provides the Admin Panel, the Content-Type Builder, and the REST API the frontend consumes.
Create the Strapi 5 Project
Run the following command in your terminal:
npx create-strapi@latest coupons-backendThe CLI prompts you through a few choices. Skip the Strapi Cloud login for local development, and pick SQLite as the database to keep prerequisites minimal. SQLite works fine for development and prototyping. Once the install finishes, you have a fully working Strapi project in the coupons-backend directory.
Start the Dev Server and Register the First Admin
Start the development server and open the Admin Panel registration page with the following command:
cd coupons-backend && npm run developYour browser opens http://localhost:1337/admin automatically. Fill in the registration form (first name, last name, email, password) to create your administrator account. After logging in, you'll find the Content-Type Builder in the left navigation, where all the data modeling happens next.
Model the Data: Stores, Categories, and Deals
The content model is the conceptual core of this project. You need three Collection Types with specific fields and two relations connecting them.
Create the Store Content Type
Open the Content-Type Builder and click Create new collection type. Enter Store as the display name (use the singular form; Strapi auto-pluralizes it in the Content Manager). Add the following fields to the Store content type:
| Field name | Type | Configuration |
|---|---|---|
name | Text (Short text) | Required, Unique |
slug | UID | Attached field: name |
logo | Media (Single image) | Allowed types: images only |
website | Text (Short text) | |
description | Rich Text (Blocks) |
Click Save to finalize the Store content type. Strapi 5 offers a Rich Text (Blocks) field type in the picker, which gives you a live-rendering editor with support for images and code blocks, while Strapi also refers to its classic rich text editor as Markdown-based. Pick Blocks for this tutorial. You can render Blocks content on the frontend later using the @strapi/blocks-react-renderer package.
Create the Category Content Type
Create another Collection Type named Category with these fields and then and click Save to finalize the Category content type.
| Field name | Type | Configuration |
|---|---|---|
name | Text (Short text) | Required, Unique |
slug | UID | Attached field: name |
icon | Text (Short text) | For an emoji or icon class name |
Create the Deal Content Type with Relations
Create a third Collection Type named Deal. This one is the most involved because it includes relations to both Store and Category:
| Field name | Type | Configuration |
|---|---|---|
title | Text (Short text) | Required |
slug | UID | Attached field: title |
description | Rich Text (Blocks) | |
code | Text (Short text) | The coupon code |
discount_value | Text (Short text) | Handles both "20%" and "$10" |
expires_at | Date (type: date) | |
featured | Boolean | Default: false |
store | Relation | Many-to-one: Deal belongs to one Store; Store has many Deals |
categories | Relation | Many-to-many: Deal has and belongs to many Categories |
For the store relation, click the second grey box to select the Store content type, then choose the many-to-one icon. For categories, select Category and choose many-to-many. Save everything.
One detail worth calling out now: Strapi 5 uses a flat response format. Each deal returns both a numeric id (for internal use) and a string documentId (use this for querying specific documents), and fields sit at the top level of the response object. There is no data.attributes wrapper like in Strapi v4. This directly affects how you read API responses in the frontend code later.
Seed Sample Data and Open the REST API
Sample data lets you verify the content model works end-to-end before writing any frontend code, and opening the REST API confirms that each endpoint returns the fields the frontend expects.
Create Test Entries in the Admin Panel
Open the Content Manager and create two or three stores, four or five categories (like Electronics, Fashion, Food, Travel), and six to eight deals spread across them. Assign each deal a store and one or more categories.
Each entry needs to be published, not just saved as a draft, for the public API to return it. This trips up first-time Strapi developers more than almost anything else. If your API returns an empty array later, unpublished entries are the most likely cause.
Enable Public Read Access
Navigate to Settings in the left sidebar, then under Users and Permissions Plugin, select Roles and click the Public role. Expand each content type (Deal, Store, Category) and check find and findOne.
Click Save. Media access is handled separately from the Users and Permissions plugin, and private media access requires additional configuration such as a private provider with signed URLs. Verify with a quick curl:
curl http://localhost:1337/api/dealsYou should see a JSON array of your published deals. If the array is empty, check these common causes: entries may still be in draft status (open the Content Manager and click Publish on each one), the Public role permissions may not have been saved (click Save at the top of the Roles page after checking the boxes), or relation fields like store and categories may appear as null because Strapi 5 does not populate relations by default.
The frontend code in the next sections demonstrates how to include explicit populate parameters on every request.
Set Up the Next.js Frontend
The Next.js 16 frontend is an App Router project that fetches data from Strapi's REST API at build time and on revalidation, then renders HTML with Server Components.
Initialize the Next.js 16 App
Scaffold the project using the official create-next-app CLI:
npx create-next-app@latest coupons-frontendAccept TypeScript, the App Router, and Tailwind CSS when prompted. Tailwind keeps the styling minimal so we can focus on data fetching and rendering.
Configure the Next.js Image component to accept Strapi's media URLs. Open next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
port: "1337",
pathname: "/uploads/**",
},
],
},
};
export default nextConfig;Restart the dev server after changing this file so Next.js picks up the new configuration.
Configure the Strapi Base URL and a Fetch Helper
Add the Strapi URL to .env.local:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337Install the qs library for query string serialization:
npm install qs
npm install --save-dev @types/qsCreate lib/strapi.ts with a reusable fetch function:
import qs from "qs";
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL ?? "http://localhost:1337";
export async function fetchAPI<T>(path: string, params?: Record<string, unknown>): Promise<T> {
const url = new URL(`/api${path}`, STRAPI_URL);
if (params) {
url.search = qs.stringify(params, { encodeValuesOnly: true });
}
const res = await fetch(url.toString(), {
headers: { "Content-Type": "application/json" },
next: { revalidate: 60 },
});
if (!res.ok) {
throw new Error(`Strapi fetch failed: ${res.status} ${res.statusText}`);
}
const json = await res.json();
return json.data as T;
}
export function getStrapiMedia(url: string | null): string | null {
if (!url) return null;
if (url.startsWith("http://") || url.startsWith("https://")) return url;
return `${STRAPI_URL}${url}`;
}Using next: { revalidate: 60 } on a fetch request enables a 60-second revalidation window for that request in Next.js. The helper unwraps Strapi's top-level data property before returning, so callers receive the main response payload directly without needing to access .data themselves. For a list endpoint like /deals, the API response returns a data array of deal objects. For a filtered query that returns one result, you get a single-element array.
Build the Deals Listing Page
The homepage displays a grid of all deals, each linked to its detail page.
Fetch Deals with Populate
In app/page.tsx, fetch from /deals with an explicit populate object. The populate=* wildcard is convenient for quick testing, but Strapi's REST populate docs recommend explicit population for production. Limit population depth to two or three levels.
import { fetchAPI, getStrapiMedia } from "@/lib/strapi";
import Image from "next/image";
import Link from "next/link";
interface Deal {
documentId: string;
title: string;
slug: string;
discount_value: string;
expires_at: string;
featured: boolean;
store: {
name: string;
logo: { url: string; alternativeText: string | null };
};
categories: { name: string; slug: string }[];
}
export default async function HomePage() {
const deals = await fetchAPI<Deal[]>('/deals', {
populate: {
store: {
fields: ['name', 'slug'],
populate: { logo: { fields: ['url', 'alternativeText'] } },
},
categories: { fields: ['name', 'slug'] },
},
sort: ['featured:desc', 'createdAt:desc'],
});
return (
<main className="max-w-6xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">Latest Deals</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{deals.map((deal) => (
<DealCard key={deal.documentId} deal={deal} />
))}
</div>
</main>
);
}Note that app/page.tsx is a Server Component by default in Next.js 16 and can be made async for data fetching. No 'use client' directive is needed here.
Render the Deals Grid
The DealCard component renders each deal's store logo, title, discount value, category badges, expiration date, and a link to /deals/[slug]:
function DealCard({ deal }: { deal: Deal }) {
const logoUrl = getStrapiMedia(deal.store?.logo?.url ?? null);
return (
<Link
href={`/deals/${deal.slug}`}
className="border rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-center gap-3 mb-3">
{logoUrl && (
<Image
src={logoUrl}
alt={deal.store.logo.alternativeText ?? deal.store.name}
width={40}
height={40}
className="rounded"
/>
)}
<span className="text-sm text-gray-500">{deal.store.name}</span>
</div>
<h2 className="text-lg font-semibold mb-2">{deal.title}</h2>
<p className="text-2xl font-bold text-green-600 mb-2">{deal.discount_value}</p>
<div className="flex gap-2 mb-2">
{deal.categories.map((cat) => (
<span key={cat.slug} className="text-xs bg-gray-100 px-2 py-1 rounded">
{cat.name}
</span>
))}
</div>
{deal.expires_at && (
<p className="text-xs text-gray-400">Expires: {deal.expires_at}</p>
)}
</Link>
);
}The grid uses Tailwind's responsive column classes: grid-cols-1 renders a single column on mobile, md:grid-cols-2 switches to two columns on tablets, and lg:grid-cols-3 displays three columns on desktop screens.
Notice that the component reads deal.store.name and deal.categories directly at the top level of the deal object. Because Strapi 5 uses the flat response format mentioned earlier, there is no intermediate .attributes wrapper to dig through. The fetchAPI helper already unwrapped the data array, so each deal object in the grid is ready to read as-is.
Construct the full media URL by combining NEXT_PUBLIC_STRAPI_URL with the relative url from the Strapi response using the getStrapiMedia helper. When Strapi media fields are populated, the returned url may be relative in some configurations, so skipping this step can result in broken images.
Build the Single Deal Page
Each deal gets its own page at /deals/[slug] with full details and a coupon code reveal button.
Generate the Route with the Slug
Create app/deals/[slug]/page.tsx. The generateStaticParams function fetches every deal slug at build time so Next.js can pre-render routes.
One gotcha worth calling out: in Next.js 16, the params prop is a Promise. You need to await it before reading params.slug. Synchronous access that was temporarily supported in Next.js 15 for backwards compatibility has been fully removed.
import { fetchAPI, getStrapiMedia } from "@/lib/strapi";
import { notFound } from "next/navigation";
import CouponReveal from "@/components/CouponReveal";
interface DealDetail {
documentId: string;
title: string;
slug: string;
code: string;
discount_value: string;
expires_at: string;
description: unknown[];
store: {
name: string;
slug: string;
logo: { url: string; alternativeText: string | null };
};
categories: { name: string; slug: string }[];
}
export async function generateStaticParams() {
const deals = await fetchAPI<{ slug: string }[]>('/deals', {
fields: ['slug'],
});
return deals.map((deal) => ({ slug: deal.slug }));
}
export default async function DealPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const deals = await fetchAPI<DealDetail[]>('/deals', {
filters: { slug: { $eq: slug } },
populate: {
store: {
fields: ['name', 'slug'],
populate: { logo: { fields: ['url', 'alternativeText'] } },
},
categories: { fields: ['name', 'slug'] },
},
});
const deal = deals[0];
if (!deal) notFound();
const logoUrl = getStrapiMedia(deal.store?.logo?.url);
return (
<main className="max-w-2xl mx-auto p-6">
<div className="flex items-center gap-4 mb-6">
{logoUrl && (
<img src={logoUrl} alt={deal.store.name} className="w-12 h-12 rounded" />
)}
<div>
<h1 className="text-2xl font-bold">{deal.title}</h1>
<p className="text-gray-500">{deal.store.name}</p>
</div>
</div>
<p className="text-3xl font-bold text-green-600 mb-4">{deal.discount_value}</p>
{deal.code && <CouponReveal code={deal.code} />}
<div className="flex gap-2 mt-4">
{deal.categories.map((cat) => (
<span key={cat.slug} className="text-sm bg-gray-100 px-3 py-1 rounded">
{cat.name}
</span>
))}
</div>
{deal.expires_at && (
<p className="text-sm text-gray-400 mt-4">Expires: {deal.expires_at}</p>
)}
</main>
);
}Fetch the Deal by Slug
The REST filter syntax for finding a deal by slug is filters[slug][$eq]=value. The fetchAPI helper and qs translate the object { filters: { slug: { $eq: slug } } } into the correct query string. If no match comes back, notFound() from next/navigation triggers the 404 page.
You might wonder why the code filters by slug on the collection endpoint instead of using Strapi's findOne with a documentId. Slugs are URL-friendly and human-readable, so the URL structure (/deals/summer-sale) is meaningful to both users and search engines.
The documentId is an internal identifier like h90lgohlzfpjf3bvan72mzll, useful for programmatic lookups but unsuitable for public-facing URLs. Filtering by slug on the /deals endpoint returns a one-element array, and the [0] access pattern with a notFound() fallback handles missing entries cleanly.
Add the Reveal-Code Interaction
This is the only Client Component in the tutorial. Create components/CouponReveal.tsx:
"use client";
import { useState } from "react";
export default function CouponReveal({ code }: { code: string }) {
const [revealed, setRevealed] = useState(false);
const [copied, setCopied] = useState(false);
async function handleReveal() {
setRevealed(true);
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
<button
onClick={handleReveal}
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-mono text-lg hover:bg-blue-700"
>
{!revealed ? "Reveal Code" : copied ? "Copied!" : code}
</button>
);
}The component manages its own state, so when multiple deal cards appear on a page, each tracks its reveal and copy state independently. Using a shared DOM id for clipboard targeting is a common bug when rendering multiple coupons. Per-component state avoids that entirely.
Add Category Filtering
Category pages display a filtered list of deals matching a specific category slug.
Create the Category Route
Create app/categories/[slug]/page.tsx following the same generateStaticParams pattern:
import { fetchAPI, getStrapiMedia } from "@/lib/strapi";
import Link from "next/link";
interface Deal {
documentId: string;
title: string;
slug: string;
discount_value: string;
store: { name: string };
categories: { name: string; slug: string }[];
}
export async function generateStaticParams() {
const categories = await fetchAPI<{ slug: string }[]>('/categories', {
fields: ['slug'],
});
return categories.map((cat) => ({ slug: cat.slug }));
}
export default async function CategoryPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const deals = await fetchAPI<Deal[]>('/deals', {
filters: {
categories: { slug: { $eq: slug } },
},
populate: {
store: { fields: ['name'] },
categories: { fields: ['name', 'slug'] },
},
});
return (
<main className="max-w-6xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">Deals in "{slug}"</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{deals.map((deal) => (
<Link
key={deal.documentId}
href={`/deals/${deal.slug}`}
className="border rounded-lg p-4 hover:shadow-md"
>
<h2 className="font-semibold">{deal.title}</h2>
<p className="text-green-600 font-bold">{deal.discount_value}</p>
<p className="text-sm text-gray-500">{deal.store.name}</p>
</Link>
))}
</div>
</main>
);
}Filter Deals by Category Slug
The key part of this component is the REST filter: filters[categories][slug][$eq]=value. The bracket syntax follows the pattern filters[relationName][fieldOnRelation][$operator]=value. The qs library serializes the nested object into this bracket notation automatically. For the full list of filter operators ($contains, $gte, $in, and others), see the REST API filters reference.
To extend this into a site-wide category navigation, fetch all categories in a layout component with fetchAPI("/categories", { fields: ["name", "slug", "icon"] }). That call returns the data you need for a shared sidebar or header nav across every page.
Since layout components in the App Router wrap their child routes, the category list stays available on the homepage, individual deal pages, and category pages without duplicating the fetch logic. You could render each category as a link to /categories/[slug], optionally showing the icon field as an emoji or CSS class prefix.
Revalidation and Going Live
The site works locally. Here's how to keep content fresh and prepare for production deployment.
Keep Deals Fresh with ISR
The fetchAPI helper already passes next: { revalidate: 60 } on every call. Each page rebuilds at most once per minute when traffic hits it. For a coupons site where stale codes frustrate users, consider adding webhook-triggered on-demand revalidation. Webhook docs show how Strapi can fire on the entry.publish event, hit a Next.js route handler at /api/revalidate, and call revalidatePath for the affected paths:
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const path = request.nextUrl.searchParams.get('path')
if (path) {
revalidatePath(path)
return Response.json({ revalidated: true, now: Date.now() })
}
return Response.json({ revalidated: false, now: Date.now() })
}In the Strapi Admin Panel, go to Settings, then Webhooks, then Add new webhook. Set the URL to your deployed Next.js app's /api/revalidate endpoint and select the entry update and publish webhook events as the trigger events.
The tradeoff between the two revalidation strategies is worth understanding. Time-based ISR (the 60-second revalidate value) is simpler to set up: no webhook configuration, no route handler to deploy, and it works identically in local development and production. The downside is the staleness window.
A content editor could publish a corrected coupon code, and users might see the old code for up to 60 seconds. On-demand revalidation through webhooks eliminates that window. The moment a deal is published or updated in Strapi, the webhook fires and the affected page rebuilds on the next request. For a coupon site where an expired or incorrect code erodes user trust, on-demand revalidation is worth the extra setup.
What to Set Up Before Deploying
- Switch the deployment docs recommendation from SQLite to PostgreSQL for production. SQLite uses file-level locking that can lead to slow requests and timeouts under concurrent load.
- Set
NEXT_PUBLIC_STRAPI_URLto the deployed Strapi URL in your frontend's environment variables. The relative/uploads/paths in media responses depend on this value being correct. - Tighten the Public role permissions to only the endpoints the frontend actually calls. Disable
create,update, anddeleteon the Public role entirely.
How Strapi Powers This Build
Each layer of the stack maps to a specific Strapi capability, from content modeling through to production revalidation:
- The Content-Type Builder models stores, deals, and categories with relations, without writing a schema file;
- Strapi 5's flat response format lets frontend components read fields at the top level of each object;
- REST API
populateandfiltershandle nested relation fetching and category-based queries in a single request; - Built-in role permissions restrict public access to read-only endpoints without custom middleware;
- Webhook support enables on-demand ISR when editors publish or update deals;
- The Content Manager gives non-technical editors a visual interface for managing deals without touching code.
From here, add an affiliate_url field to track outbound clicks, wire up a search plugin for keyword lookups, or use the Users and Permissions plugin for per-user saved deals.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.