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:
documentId. 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:
npx create-strapi@latest; the installer uses an interactive wizard to configure your database and project settings app/ directory; the App Router is the default in Next.js 16) @strapi/client for a Strapi client library experience instead of raw fetch callsYou should also have a code editor and terminal ready. The examples use TypeScript throughout, though the patterns apply equally to JavaScript projects.
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 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.
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) |
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:
1{
2 "stage": {
3 "type": "relation",
4 "relation": "manyToOne",
5 "target": "api::stage.stage",
6 "inversedBy": "deals"
7 }
8}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.
The Next.js frontend consumes the Strapi REST API from server components and renders the Kanban board. Start by scaffolding a new project:
1npx create-next-app@latest sales-pipeline --typescript
2cd 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:
1STRAPI_URL=http://localhost:1337
2STRAPI_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:
1npm install server-onlyCreate a base API utility at lib/strapi.ts:
1// lib/strapi.ts
2import 'server-only'
3
4const STRAPI_URL = process.env.STRAPI_URL!
5const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN!
6
7export async function strapiGet<T>(path: string): Promise<{ data: T; meta: unknown }> {
8 const res = await fetch(`${STRAPI_URL}/api${path}`, {
9 headers: {
10 Authorization: `Bearer ${STRAPI_API_TOKEN}`,
11 'Content-Type': 'application/json',
12 },
13 })
14 if (!res.ok) throw new Error(`Strapi API error: ${res.status}`)
15 return res.json()
16}
17
18export async function strapiPut<T>(path: string, data: unknown): Promise<{ data: T }> {
19 const res = await fetch(`${STRAPI_URL}/api${path}`, {
20 method: 'PUT',
21 headers: {
22 Authorization: `Bearer ${STRAPI_API_TOKEN}`,
23 'Content-Type': 'application/json',
24 },
25 body: JSON.stringify({ data }),
26 })
27 if (!res.ok) throw new Error(`Strapi PUT error: ${res.status}`)
28 return res.json()
29}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.
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.
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:
1GET /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:
1{
2 "data": [
3 {
4 "id": 1,
5 "documentId": "h90lgohlzfpjf3bvan72mzll",
6 "title": "Enterprise Deal",
7 "value": 50000,
8 "stage": {
9 "id": 3,
10 "documentId": "cf07g1dbusqr8mzmlbqvlegx",
11 "name": "Proposal",
12 "color": "#3b82f6"
13 },
14 "contact": {
15 "id": 7,
16 "documentId": "ab12cd34ef56gh78ij90klm",
17 "name": "Jane Smith",
18 "email": "jane@acme.com"
19 }
20 }
21 ],
22 "meta": { "pagination": { "page": 1, "pageSize": 25, "total": 4 } }
23}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:
1GET /api/stages?sort=order:ascIn your server component, use Promise.all to fire both requests simultaneously:
1// app/pipeline/page.tsx
2import { strapiGet } from '@/lib/strapi'
3import { KanbanBoard } from './KanbanBoard'
4
5export default async function PipelinePage() {
6 const [dealsRes, stagesRes] = await Promise.all([
7 strapiGet('/deals?populate[stage]=true&populate[contact]=true&sort=createdAt:desc'),
8 strapiGet('/stages?sort=order:asc'),
9 ])
10
11 return <KanbanBoard deals={dealsRes.data} stages={stagesRes.data} />
12}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:
1// app/pipeline/KanbanBoard.tsx (static version)
2export function KanbanBoard({ deals, stages }) {
3 return (
4 <div style={{ display: 'flex', gap: '1rem', overflowX: 'auto' }}>
5 {stages.map((stage) => {
6 const stageDeals = deals.filter(
7 (deal) => deal.stage?.documentId === stage.documentId
8 )
9 return (
10 <div key={stage.documentId} style={{ minWidth: 280 }}>
11 <h2 style={{ color: stage.color }}>{stage.name}</h2>
12 {stageDeals.map((deal) => (
13 <div key={deal.documentId} style={{ padding: '0.75rem', marginBottom: '0.5rem', background: 'white', borderRadius: '0.375rem' }}>
14 <p><strong>{deal.title}</strong></p>
15 <p>{deal.contact?.name}</p>
16 <p>${deal.value?.toLocaleString()}</p>
17 </div>
18 ))}
19 </div>
20 )
21 })}
22 </div>
23 )
24}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.
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:
1npm install @hello-pangea/dnd@^18.0.1Every file importing DnD components must declare 'use client' at the top. Here is the interactive board:
1// app/pipeline/KanbanBoard.tsx
2'use client'
3
4import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'
5import { useState } from 'react'
6
7export function KanbanBoard({ deals, stages }) {
8 const [columns, setColumns] = useState(() =>
9 stages.map((stage) => ({
10 ...stage,
11 deals: deals.filter((d) => d.stage?.documentId === stage.documentId),
12 }))
13 )
14
15 const onDragEnd = (result: DropResult) => {
16 const { destination, source } = result
17 if (!destination) return
18 if (destination.droppableId === source.droppableId && destination.index === source.index) return
19
20 const sourceCol = columns.find((c) => c.documentId === source.droppableId)!
21 const draggedDeal = sourceCol.deals[source.index]
22
23 const newColumns = columns.map((col) => {
24 if (col.documentId === source.droppableId) {
25 const updated = [...col.deals]
26 updated.splice(source.index, 1)
27 return { ...col, deals: updated }
28 }
29 if (col.documentId === destination.droppableId) {
30 const updated = [...col.deals]
31 updated.splice(destination.index, 0, draggedDeal)
32 return { ...col, deals: updated }
33 }
34 return col
35 })
36
37 const previous = columns
38 setColumns(newColumns)
39
40 updateDealStage(draggedDeal.documentId, destination.droppableId).catch(() => {
41 setColumns(previous)
42 })
43 }
44
45 return (
46 <DragDropContext onDragEnd={onDragEnd}>
47 <div style={{ display: 'flex', gap: '1rem' }}>
48 {columns.map((stage) => (
49 <Droppable key={stage.documentId} droppableId={stage.documentId}>
50 {(provided) => (
51 <div ref={provided.innerRef} {...provided.droppableProps} style={{ minWidth: 280, padding: '0.5rem', background: '#f3f4f6' }}>
52 <h2 style={{ color: stage.color }}>{stage.name}</h2>
53 {stage.deals.map((deal, index) => (
54 <Draggable key={deal.documentId} draggableId={deal.documentId} index={index}>
55 {(provided) => (
56 <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}
57 style={{ padding: '0.75rem', marginBottom: '0.5rem', background: 'white', borderRadius: '0.375rem', ...provided.draggableProps.style }}>
58 <p><strong>{deal.title}</strong></p>
59 <p>{deal.contact?.name}</p>
60 <p>${deal.value?.toLocaleString()}</p>
61 </div>
62 )}
63 </Draggable>
64 ))}
65 {provided.placeholder}
66 </div>
67 )}
68 </Droppable>
69 ))}
70 </div>
71 </DragDropContext>
72 )
73}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.
The stage update sends a PUT request to Strapi using the connect syntax to set the new stage relation:
1PUT /api/deals/{documentId}
2Content-Type: application/json
3
4{
5 "data": {
6 "stage": {
7 "connect": ["target-stage-documentId"]
8 }
9 }
10}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:
1// app/actions/deals.ts
2'use server'
3
4import { revalidatePath } from 'next/cache'
5
6export async function updateDealStage(dealDocumentId: string, stageDocumentId: string) {
7 const res = await fetch(`${process.env.STRAPI_URL}/api/deals/${dealDocumentId}`, {
8 method: 'PUT',
9 headers: {
10 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
11 'Content-Type': 'application/json',
12 },
13 body: JSON.stringify({ data: { stage: { connect: [stageDocumentId] } } }),
14 })
15
16 if (!res.ok) throw new Error('Failed to update deal stage')
17 revalidatePath('/pipeline')
18}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.
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.
Strapi's REST API supports filter operators through bracket syntax. To find deals from a specific company or above a minimum value:
1GET /api/deals?filters[contact][name][$containsi]=acme&populate[stage]=true&populate[contact]=true
2GET /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:
1import qs from 'qs'
2
3const query = qs.stringify({
4 filters: {
5 $and: [
6 { value: { $gte: 5000 } },
7 { contact: { name: { $containsi: 'acme' } } },
8 ],
9 },
10 populate: { stage: true, contact: true },
11}, { encodeValuesOnly: true })Remember: relations traversed by a filter are not returned in the response unless also included via populate.
Aggregate deal values client-side by stage column. Add a total at the top of each Kanban column header:
1const stageTotal = stage.deals.reduce((sum, deal) => sum + (deal.value || 0), 0)
2// 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.
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:
.attributes wrapper, so the Next.js frontend accesses deal.stage.name directly. connect relation syntax on PUT requests lets the board persist drag-and-drop stage changes with a single API call. Ready to build this yourself? Get started with Strapi Cloud and create your first Collection Type today.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.