Affiliate programs drive revenue for Software as a Service (SaaS) companies, content creators, and e-commerce brands, but tracking performance across dozens of links usually means paying for a third-party platform or cobbling together spreadsheets. This guide builds a self-hosted affiliate marketing tracker where Strapi 5 manages partners, links, and click events through its REST API, and Next.js 16 renders a dashboard that surfaces performance data in real time.
In Brief:
- Model affiliate partners, links, and click events as Strapi 5 Collection Types
- Build a custom Strapi controller that logs clicks and redirects visitors to the destination URL
- Fetch and display affiliate performance data in a Next.js 16 App Router dashboard
- Add filtering by date range and partner to surface actionable insights
Prerequisites and Tech Stack
This tutorial uses Strapi 5 for the backend API and data modeling, and Next.js 16 for the frontend dashboard. Before starting, confirm you have the following installed:
- Node.js v20, v22, or v24 (all Long-Term Support (LTS) versions supported by Strapi 5)
- Strapi 5 (installed via
npx create-strapi@latest) - Next.js 16 (App Router)
- Basic familiarity with TypeScript, REST APIs, and React Server Components
Strapi fits this use case because the Content-Type Builder lets you define the affiliate data structure through the Admin Panel, and the auto-generated REST API means you skip writing boilerplate Create, Read, Update, Delete (CRUD) endpoints. This tutorial uses REST, but you could adapt it to GraphQL by installing the GraphQL plugin. As a headless Content Management System (CMS), Strapi handles content storage and API delivery while the Next.js frontend owns the presentation layer.
Set Up the Strapi 5 Backend
The backend setup involves scaffolding a new Strapi project and generating TypeScript types for your content models.
Scaffold a New Strapi Project
Create the project from your terminal:
npx create-strapi@latest affiliate-trackerSyntax note: The CLI uses npx create-strapi (with a dash) or npm create strapi (no dash). Both work, but don't mix up the forms.
The scaffolding process prompts you to log in or skip. Skipping defaults to the CMS Free plan. The default SQLite database is fine for development. Start the dev server:
cd affiliate-tracker
npm run developRegister the first admin user at http://localhost:1337/admin. The Admin Panel is where you'll build the data model next. For a deeper walkthrough of first-time setup, check the Strapi getting started guide.
Generate TypeScript Types
Run this command after creating each content type to keep your type definitions current:
npx strapi ts:generate-typesRun it again after every schema change. The generated types give you autocomplete and type safety across your custom controllers.
Sign up for the Logbook, Strapi's Monthly newsletter
Model the Affiliate Data Structure in Strapi
You need three Collection Types that relate to each other: partners who own affiliate links, the links themselves, and click events that log every redirect.
Create the Partner Collection Type
Open the Content-Type Builder in the Admin Panel and create a new Collection Type called Partner with these fields:
name(Text, required, unique)email(Email)commissionRate(Decimal)isActive(Boolean, default:true)
One thing to know about Strapi 5's API responses: the format is flat. Fields sit directly on the data object, not nested under data.attributes like in Strapi 4. And each entry uses a 24-character documentId string as its unique identifier instead of a numeric id. Both changes affect how you read API responses throughout this tutorial.
The Content-Type Builder is only accessible in development environments. Define your full schema before deploying to production.
Create the Affiliate Link Collection Type
Create another Collection Type called Affiliate Link:
slug(Unique Identifier (UID), required, unique): used in the tracking URLdestinationUrl(Text, required): where the visitor lands after the redirectcampaign(Text): optional grouping labelpartner(Relation: many-to-one → Partner)
The relation setup here means each link belongs to one partner, but a partner can own many links. Configure this in the Content-Type Builder by selecting "Many-to-one" with Partner as the target.
Create the Click Event Collection Type
Create the third Collection Type, Click Event:
referrer(Text)userAgent(Text)ipHash(Text): a hashed value, not the raw IP address (for privacy compliance)affiliateLink(Relation: many-to-one → Affiliate Link)
This collection is your event log. Every click creates a new entry. Disable Draft & Publish on this type in the Advanced Settings tab since click events don't need editorial workflow.
The Content-Type Builder's field configuration options include field validation, default values, and relation cardinality. Configure validation rules, like marking referrer as optional, during creation.
Strapi's content architecture supports these relational patterns out of the box, so the three Collection Types automatically generate REST endpoints at /api/partners, /api/affiliate-links, and /api/click-events. The default CRUD endpoints handle standard operations, but the tracking redirect needs custom logic.
Build a Custom Click-Tracking Controller
The auto-generated endpoints don't support "log a click, then redirect." You need a custom route and controller for the GET /api/track/:slug endpoint that visitors hit when they click an affiliate link.
Register a Custom Route
Create a custom route file. The 01- prefix ensures it loads before core routes because file loading is alphabetical:
// src/api/affiliate-link/routes/01-custom-routes.js
module.exports = {
routes: [
{
method: 'GET',
path: '/track/:slug',
handler: 'api::affiliate-link.custom-controller.track',
config: {
auth: false,
},
},
],
};Setting auth: false bypasses the authentication system for this route. Affiliate links need to work for unauthenticated visitors, so this is usually the simplest option for a tracking endpoint. For more on creating custom API endpoints, the Strapi blog covers advanced patterns.
Write the Redirect Controller
Create the controller at src/api/affiliate-link/controllers/custom-controller.js:
// src/api/affiliate-link/controllers/custom-controller.js
const crypto = require('crypto');
module.exports = {
async track(ctx) {
const { slug } = ctx.request.params;
// Look up the affiliate link by slug
const links = await strapi
.documents('api::affiliate-link.affiliate-link')
.findMany({
filters: { slug: { $eq: slug } },
status: 'published',
});
if (!links || links.length === 0) {
return ctx.notFound('Affiliate link not found');
}
const link = links[0];
// Hash the IP for privacy, and never store the raw address
const ipHash = crypto
.createHash('sha256')
.update(ctx.request.ip + process.env.APP_KEYS)
.digest('hex')
.substring(0, 16);
// Log the click event
await strapi.documents('api::click-event.click-event').create({
data: {
referrer: ctx.request.header.referer || '',
userAgent: ctx.request.header['user-agent'] || '',
ipHash,
affiliateLink: link.documentId,
},
});
// Redirect to the destination
ctx.redirect(link.destinationUrl);
},
};A few implementation details matter here:
- The Document Service API (
strapi.documents()) is the Strapi 5 approach for interacting with content. findManywith afiltersobject lets you look up a record bysluginstead ofdocumentId.ctx.redirect()issues the redirect after the click event is written.
There is a tradeoff here. The click-event write happens before the redirect, so every visitor waits for that database insert to finish. For low-to-moderate traffic, that's often acceptable. If traffic grows enough that redirects start feeling slow, you can move event processing out of the request path later.
Configure Public Access for the Tracking Route
Because you set auth: false in the route config, the endpoint already bypasses authentication at the code level.
If you prefer managing permissions through the Admin Panel instead, navigate to Settings → Users & Permissions plugin → Roles, then click the edit icon next to the Public role, expand the relevant permission category, and enable the track action. The users and permissions guide covers this workflow in detail, and the roles and permissions guide on the Strapi blog walks through more advanced access control patterns.
The tradeoff: auth: false in the route config can't be accidentally reverted through the User Interface (UI), while the Admin Panel approach gives non-developers a way to toggle access.
Connect Next.js 16 to the Strapi API
With the backend running, set up a Next.js 16 project that fetches affiliate data from Strapi's REST API and renders it in server components.
Initialize the Next.js Project
Scaffold a new Next.js 16 app with TypeScript and the App Router enabled:
npx create-next-app@latest affiliate-dashboard --typescript --appThe Next.js 16 CLI defaults to TypeScript, Tailwind CSS, and Turbopack. Once scaffolded, install the official Strapi client:
cd affiliate-dashboard
npm install @strapi/clientAdd environment variables to .env.local:
STRAPI_API_URL=http://localhost:1337
STRAPI_API_TOKEN=your-read-only-api-tokenGenerate an API token in the Strapi Admin Panel under Settings → API Tokens. A read-only token is sufficient for the dashboard since it only needs find and findOne access.
For more on the Strapi and Next.js integration, the official integration page covers additional configuration options.
Create a Strapi Client Utility
Rather than calling fetch directly in every component, centralize the Strapi connection in a single utility module. This keeps the base URL and API token in one place and gives every server component a consistent client instance to import:
// lib/strapi.ts
import { strapi } from '@strapi/client';
const client = strapi({
baseURL: `${process.env.STRAPI_API_URL}/api`,
auth: process.env.STRAPI_API_TOKEN,
});
export default client;The @strapi/client library is intended for interacting with the Strapi REST API. You access fields directly on the data object (result.data[0].name) without unwrapping data.attributes.
When the Strapi server is unreachable (network failure, wrong URL, or server downtime), the @strapi/client call throws an error. In server components, an unhandled error bubbles up and crashes the page render. Wrap data fetching calls in try/catch blocks to handle failures gracefully.
Next.js App Router supports error.tsx files at any route segment level. Placing one in app/dashboard/error.tsx catches rendering failures in the dashboard route and displays a fallback UI instead of a blank page. For transient errors like timeouts, logging the error and returning empty data with a user-visible message is a reasonable default.
Fetch Affiliate Data in Server Components
React Server Components in the App Router fetch data on the server by default. No useEffect or client-side state management is needed for initial loads:
// app/dashboard/page.tsx
import client from '@/lib/strapi';
export default async function DashboardPage() {
const result = await client.collection('affiliate-links').find({
populate: ['clickEvents', 'partner'],
sort: ['createdAt:desc'],
});
return (
<ul>
{result.data.map((link) => (
<li key={link.documentId}>
{link.slug} — {link.clickEvents?.length || 0} clicks
</li>
))}
</ul>
);
}One important Next.js 16 change: fetch requests are no longer cached by default. For a dashboard displaying click data, this is what you want since the numbers should be fresh on every load.
Build the Affiliate Dashboard UI
The dashboard needs three pieces: a summary table showing clicks per partner, a line chart for click trends over time, and filters for narrowing by date range or partner.
Display Partner Performance in a Summary Table
Build a server component that aggregates click data per partner:
// app/dashboard/page.tsx
import client from '@/lib/strapi';
import ClickChart from '@/components/click-chart';
import Filters from '@/components/filters';
import { Suspense } from 'react';
export default async function DashboardPage(props: {
searchParams: Promise<{ from?: string; to?: string; partner?: string }>;
}) {
const searchParams = await props.searchParams;
const { from, to, partner } = searchParams;
const partners = await client.collection('partners').find({
populate: {
affiliateLinks: {
populate: ['clickEvents'],
},
},
sort: ['name:asc'],
});
const filteredPartners = partners.data
.filter((p) => !partner || p.documentId === partner)
.map((p) => ({
...p,
affiliateLinks:
p.affiliateLinks?.map((link) => ({
...link,
clickEvents:
link.clickEvents?.filter((event) => {
const createdAt = event.createdAt;
if (from && createdAt < from) return false;
if (to && createdAt > to) return false;
return true;
}) || [],
})) || [],
}));
return (
<main>
<h1>Affiliate Dashboard</h1>
<Suspense fallback={<p>Loading filters...</p>}>
<Filters partners={partners.data} />
</Suspense>
<table>
<thead>
<tr>
<th>Partner</th>
<th>Total Links</th>
<th>Total Clicks</th>
<th>Commission Rate</th>
</tr>
</thead>
<tbody>
{filteredPartners.map((p) => {
const totalClicks =
p.affiliateLinks?.reduce(
(sum, link) => sum + (link.clickEvents?.length || 0),
0
) || 0;
return (
<tr key={p.documentId}>
<td>{p.name}</td>
<td>{p.affiliateLinks?.length || 0}</td>
<td>{totalClicks}</td>
<td>{p.commissionRate}%</td>
</tr>
);
})}
</tbody>
</table>
<Suspense fallback={<p>Loading chart...</p>}>
<ClickChart partners={filteredPartners} />
</Suspense>
</main>
);
}In Next.js 16, searchParams is a Promise, a breaking change from v15. You must await it before reading values. For additional patterns on building data-rich frontends with the Strapi API, explore the developer tutorials.
Add a Click Trend Chart
Charts require interactivity, so this is a client component. Install Recharts with npm install recharts, then aggregate the data on the server and pass it down as props:
// components/click-chart.tsx
'use client';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
interface ClickChartProps {
partners: any[];
}
export default function ClickChart({ partners }: ClickChartProps) {
// Aggregate clicks by date across all partners
const clicksByDate: Record<string, number> = {};
partners.forEach((partner) => {
partner.affiliateLinks?.forEach((link: any) => {
link.clickEvents?.forEach((event: any) => {
const date = new Date(event.createdAt).toISOString().split('T')[0];
clicksByDate[date] = (clicksByDate[date] || 0) + 1;
});
});
});
const chartData = Object.entries(clicksByDate)
.map(([date, clicks]) => ({ date, clicks }))
.sort((a, b) => a.date.localeCompare(b.date));
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="clicks" stroke="#4f46e5" />
</LineChart>
</ResponsiveContainer>
);
}Filter by Date Range and Partner
Add URL search params for from, to, and partner filters. The client component syncs filter state with the URL, while the server component reads params and passes filtered data down:
// components/filters.tsx
'use client';
import { useSearchParams, useRouter } from 'next/navigation';
export default function Filters({ partners }: { partners: any[] }) {
const searchParams = useSearchParams();
const router = useRouter();
function updateFilter(key: string, value: string) {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
router.push(`/dashboard?${params.toString()}`);
}
return (
<div>
<label>
From:
<input
type="date"
defaultValue={searchParams.get('from') || ''}
onChange={(e) => updateFilter('from', e.target.value)}
/>
</label>
<label>
To:
<input
type="date"
defaultValue={searchParams.get('to') || ''}
onChange={(e) => updateFilter('to', e.target.value)}
/>
</label>
<label>
Partner:
<select
defaultValue={searchParams.get('partner') || ''}
onChange={(e) => updateFilter('partner', e.target.value)}
>
<option value="">All partners</option>
{partners.map((partner) => (
<option key={partner.documentId} value={partner.documentId}>
{partner.name}
</option>
))}
</select>
</label>
</div>
);
}On the server side, apply these params before rendering the table and chart. The from and to values scope which click events are counted, and the partner value narrows the dashboard to a single partner. For more examples of filter composition, refer to the Strapi REST filters documentation.
Suspense requirement: useSearchParams in a client component will cause the component tree up to the closest Suspense boundary to be client-side rendered. Wrap filter components that use useSearchParams in a <Suspense> boundary, as shown in the dashboard page above.
Test the Full Tracking Flow
With both the Strapi backend and Next.js frontend running locally, walk through each step manually to confirm the full cycle works end to end, from click tracking and redirect, through data storage, to dashboard rendering:
- Create test data. In the Strapi Admin Panel, add a partner (for example, "Acme Corp" with a 10% commission rate) and an affiliate link (slug:
acme-landing, destination:https://example.com). Link the affiliate link to the partner. - Trigger a click. Visit
http://localhost:1337/api/track/acme-landingin your browser. You should land onhttps://example.com. - Verify the click record. Open the Click Event collection in the Strapi Admin Panel. A new entry should appear with the referrer, user agent, and hashed IP fields populated.
- Check the dashboard. Open
http://localhost:3000/dashboardin your Next.js app. The partner table should show one click for Acme Corp. - Test filters. Set the "From" date to today in the dashboard filter. The click should still appear in the table. Set the "From" date to tomorrow. The table should show zero clicks for Acme Corp, confirming that date range filtering works correctly.
If the redirect or click recording doesn't behave as expected, use curl -v to inspect the HTTP response headers and status code directly:
curl -v http://localhost:1337/api/track/acme-landingYou should see a 302 Found response with a Location header pointing to the destination URL. Each API endpoint Strapi generates follows a predictable URL pattern, so the tracking route behaves consistently with the rest of your API.
Common issues and how to fix them:
- 403 Forbidden: Double-check that
auth: falseis set in the route config or that the Public role has thetrackaction enabled. The Strapi Users & Permissions documentation covers troubleshooting permission issues. - 404 Not Found: The slug in the URL doesn't match any published affiliate link. Verify the slug value in the Admin Panel and confirm the entry is published, not in draft state.
- Click events not appearing: If the redirect works but no Click Event entries appear, check that Draft & Publish is disabled on the Click Event collection type. When Draft & Publish is enabled, newly created entries default to draft status and won't be visible unless explicitly published.
- Malformed destination URL: If the redirect lands on an error page, check the
destinationUrlfield. It must include the protocol (for example,https://example.com, not justexample.com).
Deploy and Scale Your Affiliate Tracker
Moving from development to production requires deploying both the Strapi backend and the Next.js frontend, plus switching to a production-grade database.
Strapi backend: Deploy to Strapi Cloud for a managed experience (free plan available, with paid Essential plans starting at $15/project), or self-host using platforms such as Render (official guide available) or other third-party hosts like DigitalOcean or Railway. Switch from SQLite to PostgreSQL for production. Strapi Cloud provisions a managed PostgreSQL instance automatically; self-hosted deployments need a separate database.
For self-hosted PostgreSQL, set these environment variables in your production configuration:
DATABASE_CLIENT=postgresDATABASE_HOSTDATABASE_PORT(default: 5432)DATABASE_NAMEDATABASE_USERNAMEDATABASE_PASSWORD
Enable SSL with DATABASE_SSL=true when connecting to a managed database provider.
Next.js frontend: Deploy to Vercel or any Node.js host. Set the STRAPI_API_URL and STRAPI_API_TOKEN environment variables in your production config. On Vercel, add these under Settings → Environment Variables for your project. Make sure the STRAPI_API_URL points to your production Strapi instance, not localhost.
Scaling considerations: As click volume grows, raw event queries on every dashboard load become slow. Add a database index on the affiliateLink relation column in the click-event table. For high-traffic programs, consider pre-aggregating click counts on a schedule rather than counting rows in real time. PgBouncer helps with connection pooling between Strapi and PostgreSQL under load.
To learn more about why Strapi is a good fit for projects like this, the comparison page breaks down how it handles API generation, permissions, and content modeling.
Ready to build your own? Start with Strapi Cloud and have a project running in minutes.
Extend the Tracker
The core loop is in place: log clicks, redirect visitors, display performance. Here are concrete next steps:
- Webhook notifications. Trigger alerts when a partner hits a click milestone using Strapi's built-in webhooks. The
entry.createevent on Click Event can fire to Slack, email, or a custom endpoint. - Urchin Tracking Module (UTM) parameter parsing. Extend the click controller to capture
utm_source,utm_medium, andutm_campaignfrom the query string and store them on the click event. - Partner-facing portal. Add authenticated routes so affiliates can log in and check their own stats. Strapi's Users & Permissions plugin supports this out of the box.
- Comma-Separated Values (CSV) export. Add a server action or API route that serializes click data to CSV for reporting.
The full Strapi documentation and community forums are the best resources when you hit an edge case or want to explore advanced patterns.
How Strapi Powers This
This tutorial built a self-hosted affiliate marketing tracker that logs clicks, redirects visitors, and displays partner performance in a filterable dashboard. Strapi 5 enabled this approach through:
- Content-Type Builder modeled partners, affiliate links, and click events without writing a schema file.
- Document Service API provided a clean interface for querying links by slug and creating click event records inside the custom controller.
- Auto-generated REST endpoints handled standard CRUD operations for all three Collection Types, eliminating boilerplate.
- Flat response format with
documentIdsimplified frontend data access across every API call. - Custom routes and controllers extended the default API with a tracking redirect endpoint that Strapi served alongside its core routes.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.