Most sales teams outgrow spreadsheets fast. Deals get lost, stages go stale, and nobody agrees on what "Qualified" actually means. A dedicated sales pipeline app fixes that, but building one from scratch usually means weeks of backend work before you even touch the frontend.
Strapi 5, a headless CMS, and Next.js 16 compress that timeline significantly. You model your deals, contacts, and pipeline stages as Collection Types in Strapi, expose them through the REST Application Programming Interface (API), and render a Kanban board in Next.js where cards can be dragged between columns to update deal stages in real time.
This tutorial walks through the full build: data modeling, API integration, a drag-and-drop board, and practical extensions like filtering and value summaries.
In brief:
- Model deals, contacts, and pipeline stages in Strapi 5 using Collection Types and relations, then expose them through the REST API.
- Fetch pipeline data in Next.js 16 server components and render a Kanban-style board where deals flow through stages like Qualified, Proposal, and Closed Won.
- Update deal stages in real time with drag-and-drop interactions that PUT back to Strapi using
documentId. - Extend the app with filters, deal value summaries, and role-based access using Strapi's built-in permissions.
Prerequisites
This project pairs a Strapi 5 backend with a Next.js 16 frontend, so both runtimes and a few supporting tools need to be in place before you write any code. Confirm your environment meets these requirements:
- Node.js v20.9 or later (Next.js 16 requires Node.js 20.9+; odd-numbered Node releases like v23 are also not supported by Strapi)
- Strapi 5, installed via
npx create-strapi@latest; the installer uses an interactive wizard to configure your database and project settings - Next.js 16 with the App Router (
app/directory; the App Router is the default in Next.js 16) - Basic familiarity with React Server Components and REST APIs
- Optional:
@strapi/clientfor a Strapi client library experience instead of rawfetchcalls
You should also have a code editor and terminal ready. The examples use TypeScript throughout, though the patterns apply equally to JavaScript projects.
Model the Sales Pipeline in Strapi
The pipeline app uses three Collection Types, one for each sales concept: stages that deals flow through, contacts on the other side of those deals, and the deals themselves.
Open the Strapi Admin Panel and navigate to the Content-Type Builder. This tool is only available for creating and updating content types in a development environment; in other environments it is read-only, so make sure Strapi is running in development mode locally.
Create the Pipeline Stage Collection Type
Create a new Collection Type with the display name Stage. Strapi auto-generates the API ID as stage, which produces the endpoint /api/stages.
Add these fields:
| Field | Type | Settings |
|---|---|---|
name | Text (Short) | Required, Unique |
order | Number (Integer) | Used for column sorting |
color | Text (Short) | Hex code for UI headers |
Save the content type, then open the Content Manager and seed five or six default stages: Lead, Qualified, Proposal, Negotiation, Closed Won, Closed Lost. Set order values (1 through 6) so columns render left-to-right. Assign distinct hex colors like #6366f1 for Proposal or #22c55e for Closed Won.
Create the Contact Collection Type
A Contact represents the person or company on the other side of a deal. Storing contacts as their own Collection Type, rather than as fields on the Deal itself, lets multiple deals reference the same contact and keeps your data normalized. Create a Contact Collection Type with these fields:
| Field | Type | Settings |
|---|---|---|
name | Text (Short) | Required |
email | ||
company | Text (Short) | |
phone | Text (Short) |
Create the Deal Collection Type
The Deal Collection Type is the central entity in the pipeline. Each Deal record captures a potential sale: its monetary value, expected close date, and which Stage and Contact it belongs to through relational fields. Create it with the following fields:
| Field | Type | Settings |
|---|---|---|
title | Text (Short) | Required |
value | Number (Decimal) | Monetary amount |
notes | Rich Text (Blocks) | |
expectedCloseDate | Date | |
stage | Relation | Many-to-one with Stage |
contact | Relation | Many-to-one with Contact |
For the stage relation: add a Relation field, select Stage as the target, and click the many-to-one icon (many Deals belong to one Stage).
Name the field stage on the Deal side. Repeat this process for contact, using the same many-to-one configuration so that multiple Deals can reference a single Contact. If you check the schema file at ./src/api/deal/content-types/deal/schema.json, the stage relation looks like this:
{
"stage": {
"type": "relation",
"relation": "manyToOne",
"target": "api::stage.stage",
"inversedBy": "deals"
}
}Configure API Permissions
In Strapi, API access is controlled by role-based permissions. No endpoint responds until you explicitly grant access.
Navigate to Settings → Users & Permissions plugin → Roles → Public (or Authenticated, depending on your setup). For each of the three content types, enable these actions:
| Content Type | Actions to Enable |
|---|---|
| Stage | find, findOne |
| Contact | find, findOne |
| Deal | find, findOne, update |
The update permission on Deal is required for drag-and-drop stage changes. When you tick each checkbox, the Admin Panel shows the bound routes in the right panel, confirming exactly which endpoints you're opening.
For production, consider using a custom API Token (Settings → Global settings → API Tokens) with only these specific permissions. Store it as a server-only environment variable in your Next.js app.
Set Up the Next.js Frontend
The Next.js frontend consumes the Strapi REST API from server components and renders the Kanban board. Start by scaffolding a new project:
npx create-next-app@latest sales-pipeline --typescript
cd sales-pipelineIn Next.js 16, the App Router is the default scaffold, and next dev / next build run on Turbopack out of the box. Accept the defaults at each prompt (TypeScript, ESLint, Tailwind CSS, App Router, Turbopack). The app/ directory is required for everything that follows.
Create a .env.local file with your Strapi connection details:
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-api-token-hereUse STRAPI_URL (no NEXT_PUBLIC_ prefix) since all API calls happen in server components. A NEXT_PUBLIC_ variable would be inlined into the client bundle at build time, exposing your API token.
Install the server-only package to enforce this boundary:
npm install server-onlyCreate a base API utility at lib/strapi.ts:
// lib/strapi.ts
import 'server-only'
const STRAPI_URL = process.env.STRAPI_URL!
const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN!
export async function strapiGet<T>(path: string): Promise<{ data: T; meta: unknown }> {
const res = await fetch(`${STRAPI_URL}/api${path}`, {
headers: {
Authorization: `Bearer ${STRAPI_API_TOKEN}`,
'Content-Type': 'application/json',
},
})
if (!res.ok) throw new Error(`Strapi API error: ${res.status}`)
return res.json()
}
export async function strapiPut<T>(path: string, data: unknown): Promise<{ data: T }> {
const res = await fetch(`${STRAPI_URL}/api${path}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${STRAPI_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ data }),
})
if (!res.ok) throw new Error(`Strapi PUT error: ${res.status}`)
return res.json()
}The import 'server-only' line causes a build-time error if this module is ever imported into a Client Component, preventing accidental token leaks.
As an alternative to raw fetch, the @strapi/client Software Development Kit (SDK) provides a REST-oriented API for fetching, creating, updating, and deleting content, with support for structured query parameters such as filters and pagination.
Fetch and Display Pipeline Data
With the content model in place and Next.js scaffolded, the next step is pulling deal data from Strapi's REST API and rendering it as a Kanban board.
Query Deals with Population and Sorting
Strapi 5 does not include relations in API responses by default. You need to explicitly request them with the populate parameter. Use explicit field-level population rather than populate=* to keep responses predictable:
GET /api/deals?populate[stage]=true&populate[contact]=true&sort=createdAt:descStrapi 5 uses a flat response format: attributes sit directly on the data object, not nested under an attributes key. The response looks like this:
{
"data": [
{
"id": 1,
"documentId": "h90lgohlzfpjf3bvan72mzll",
"title": "Enterprise Deal",
"value": 50000,
"stage": {
"id": 3,
"documentId": "cf07g1dbusqr8mzmlbqvlegx",
"name": "Proposal",
"color": "#3b82f6"
},
"contact": {
"id": 7,
"documentId": "ab12cd34ef56gh78ij90klm",
"name": "Jane Smith",
"email": "jane@acme.com"
}
}
],
"meta": { "pagination": { "page": 1, "pageSize": 25, "total": 4 } }
}Access relation data directly: deal.stage.name, not deal.stage.data.attributes.name. All IDs passed to update endpoints must be documentId strings, not numeric id.
Fetch pipeline stages separately to define column order:
GET /api/stages?sort=order:ascIn your server component, use Promise.all to fire both requests simultaneously:
// app/pipeline/page.tsx
import { strapiGet } from '@/lib/strapi'
import { KanbanBoard } from './KanbanBoard'
export default async function PipelinePage() {
const [dealsRes, stagesRes] = await Promise.all([
strapiGet('/deals?populate[stage]=true&populate[contact]=true&sort=createdAt:desc'),
strapiGet('/stages?sort=order:asc'),
])
return <KanbanBoard deals={dealsRes.data} stages={stagesRes.data} />
}Build the Kanban Board Component
The board renders one column per stage, grouping deals by deal.stage.documentId. Each column displays a header (stage name with its color) and a list of deal cards showing title, contact name, and value.
The page-level server component handles data fetching. The board wrapper itself needs 'use client' only when drag-and-drop is added in the next section. For the initial static render:
// app/pipeline/KanbanBoard.tsx (static version)
export function KanbanBoard({ deals, stages }) {
return (
<div style={{ display: 'flex', gap: '1rem', overflowX: 'auto' }}>
{stages.map((stage) => {
const stageDeals = deals.filter(
(deal) => deal.stage?.documentId === stage.documentId
)
return (
<div key={stage.documentId} style={{ minWidth: 280 }}>
<h2 style={{ color: stage.color }}>{stage.name}</h2>
{stageDeals.map((deal) => (
<div key={deal.documentId} style={{ padding: '0.75rem', marginBottom: '0.5rem', background: 'white', borderRadius: '0.375rem' }}>
<p><strong>{deal.title}</strong></p>
<p>{deal.contact?.name}</p>
<p>${deal.value?.toLocaleString()}</p>
</div>
))}
</div>
)
})}
</div>
)
}Add Drag-and-Drop Stage Updates
A static board gives visibility, but the real value comes from dragging deals between columns to update their stage. Each drop triggers a PUT request to Strapi, persisting the change immediately.
Implement Drag-and-Drop with a Client Component
Drag-and-drop requires browser event handlers and local state to track card positions during a drag, which means this part of the board must run as a React Client Component. The @hello-pangea/dnd library handles the drag lifecycle: it wraps each column in a Droppable zone, each card in a Draggable element, and fires an onDragEnd callback when the user releases a card.
Install @hello-pangea/dnd, the actively maintained fork of react-beautiful-dnd with React 19 support:
npm install @hello-pangea/dnd@^18.0.1Every file importing DnD components must declare 'use client' at the top. Here is the interactive board:
// app/pipeline/KanbanBoard.tsx
'use client'
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'
import { useState } from 'react'
export function KanbanBoard({ deals, stages }) {
const [columns, setColumns] = useState(() =>
stages.map((stage) => ({
...stage,
deals: deals.filter((d) => d.stage?.documentId === stage.documentId),
}))
)
const onDragEnd = (result: DropResult) => {
const { destination, source } = result
if (!destination) return
if (destination.droppableId === source.droppableId && destination.index === source.index) return
const sourceCol = columns.find((c) => c.documentId === source.droppableId)!
const draggedDeal = sourceCol.deals[source.index]
const newColumns = columns.map((col) => {
if (col.documentId === source.droppableId) {
const updated = [...col.deals]
updated.splice(source.index, 1)
return { ...col, deals: updated }
}
if (col.documentId === destination.droppableId) {
const updated = [...col.deals]
updated.splice(destination.index, 0, draggedDeal)
return { ...col, deals: updated }
}
return col
})
const previous = columns
setColumns(newColumns)
updateDealStage(draggedDeal.documentId, destination.droppableId).catch(() => {
setColumns(previous)
})
}
return (
<DragDropContext onDragEnd={onDragEnd}>
<div style={{ display: 'flex', gap: '1rem' }}>
{columns.map((stage) => (
<Droppable key={stage.documentId} droppableId={stage.documentId}>
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps} style={{ minWidth: 280, padding: '0.5rem', background: '#f3f4f6' }}>
<h2 style={{ color: stage.color }}>{stage.name}</h2>
{stage.deals.map((deal, index) => (
<Draggable key={deal.documentId} draggableId={deal.documentId} index={index}>
{(provided) => (
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}
style={{ padding: '0.75rem', marginBottom: '0.5rem', background: 'white', borderRadius: '0.375rem', ...provided.draggableProps.style }}>
<p><strong>{deal.title}</strong></p>
<p>{deal.contact?.name}</p>
<p>${deal.value?.toLocaleString()}</p>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
))}
</div>
</DragDropContext>
)
}Two things to note: provided.placeholder inside every <Droppable> is mandatory. Omitting it causes the column to collapse during drag. And provided.draggableProps.style must be spread onto the draggable element for transform-based drag animation to work.
Using deal.documentId as the draggableId means the onDragEnd handler already has the correct identifier for the Strapi PUT request. No lookup required.
Handle the Strapi Update Request
The stage update sends a PUT request to Strapi using the connect syntax to set the new stage relation:
PUT /api/deals/{documentId}
Content-Type: application/json
{
"data": {
"stage": {
"connect": ["target-stage-documentId"]
}
}
}Strapi 5 manages relations through connect, disconnect, and set parameters. For a many-to-one relation like stage, connect with a single-element array replaces the current value.
The client-side function routes through a Next.js Server Action to keep the API token server-side:
// app/actions/deals.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function updateDealStage(dealDocumentId: string, stageDocumentId: string) {
const res = await fetch(`${process.env.STRAPI_URL}/api/deals/${dealDocumentId}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ data: { stage: { connect: [stageDocumentId] } } }),
})
if (!res.ok) throw new Error('Failed to update deal stage')
revalidatePath('/pipeline')
}The optimistic update pattern here is important: state changes immediately when the user drops a card, and only reverts if the server call fails. This keeps the UI responsive even on slower connections.
Extend the Pipeline with Filters and Summaries
A Kanban board that displays every deal at once loses its usefulness as the pipeline grows. Sales reps need to narrow the view by contact or minimum deal value, and managers need a quick read on how much revenue sits in each stage. Strapi's REST API filtering and a bit of client-side aggregation cover both needs without additional backend work.
Filter Deals by Contact or Value
Strapi's REST API supports filter operators through bracket syntax. To find deals from a specific company or above a minimum value:
GET /api/deals?filters[contact][name][$containsi]=acme&populate[stage]=true&populate[contact]=true
GET /api/deals?filters[value][$gte]=10000&populate[stage]=trueThe $containsi operator performs case-insensitive substring matching. Add a filter bar above the board with inputs for contact name and minimum deal value. On filter change, re-fetch with the updated query parameters and pass fresh data to the KanbanBoard component.
For complex queries combining multiple conditions, the qs library helps construct nested filter objects:
import qs from 'qs'
const query = qs.stringify({
filters: {
$and: [
{ value: { $gte: 5000 } },
{ contact: { name: { $containsi: 'acme' } } },
],
},
populate: { stage: true, contact: true },
}, { encodeValuesOnly: true })Remember: relations traversed by a filter are not returned in the response unless also included via populate.
Display Deal Value Totals Per Stage
Aggregate deal values client-side by stage column. Add a total at the top of each Kanban column header:
const stageTotal = stage.deals.reduce((sum, deal) => sum + (deal.value || 0), 0)
// Render: "Proposal: $47,500"This gives an instant read on pipeline health. For large datasets where client-side aggregation becomes a bottleneck, consider building a custom Strapi controller that returns pre-aggregated values per stage.
How Strapi Powers This
This tutorial built a working sales pipeline: data modeling, a Kanban board with drag-and-drop, filtered queries, and per-stage value totals. Strapi 5 enabled this approach through:
- The Content-Type Builder models stages, deals, and contacts without writing schema files, and relations between them wire up through the Admin Panel.
- Strapi 5's flat response format removes the nested
.attributeswrapper, so the Next.js frontend accessesdeal.stage.namedirectly. - REST API filter operators handle contact-based and value-based queries with bracket syntax, no custom endpoints required.
- The
connectrelation syntax on PUT requests lets the board persist drag-and-drop stage changes with a single API call. - Role-based permissions and API Tokens lock down write access so only authorized requests can move deals between stages.
Ready to build this yourself? Get started with Strapi Cloud and create your first Collection Type today.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.