Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Build it: screens and apps
  4. React CRM Template: Pipeline, Forecast, Company Pages
Auf Deutsch lesen
  1. Blog
  2. Build it: screens and apps
  3. React CRM Template: Pipeline, Forecast, Company Pages
Build it: screens and apps
Build it: screens and apps

React CRM Template: Pipeline, Forecast, Company Pages

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 11 min read

On this page

0%
  1. What should a React CRM template include?
  2. Should you buy a CRM template or assemble one from blocks?
  3. How do you lay out a Next.js CRM template?
  4. How do you build the deal pipeline page?
    1. How does the weighted forecast work?
  5. How do you build the company record page?
  6. How does the CRM work on a phone?
  7. Can you use the deal pipeline kanban on its own?
  8. What should you build next?
  9. FAQ
    1. Is there a free React CRM template?
    2. How is a weighted pipeline forecast calculated?
    3. Where should stage probabilities come from?
    4. Can I use a React CRM template with my own backend?
    5. How does a sales pipeline board work on a phone?

TL;DR

A React CRM template needs three screens before anything else: a deal pipeline board with a weighted forecast, a deal sheet for editing one deal, and a company record page with activity, deals, files and notes. In Next.js you can assemble them from the Wingo UI CRM Pipeline Page and CRM Contact Page blocks: server pages load the data, a small client view passes Server Actions to the block, and every move saves optimistically with a rollback on failure. On phones the board shows one stage at a time and the sheets and menus open from the bottom.

Published Oct 16, 2026

You are building a CRM front end, for a client or for your own product, and you search for a react crm template. The results are theme marketplaces with admin kits of dozens of pages, open-source CRMs with their own backend, and a few starters that show a kanban board with sample numbers. What you need is smaller and harder: a deal pipeline where the totals mean something, a way to edit a deal without leaving the board, and a company page that holds the whole relationship. This tutorial builds those three screens in Next.js from Wingo UI blocks, wired to Server Actions, with the final code.

What should a React CRM template include?

Most of a CRM's value sits in three screens. Everything else (tasks, email sync, reports) can come later.

  1. The pipeline board. Deals as cards in stage columns, each column with a count, a total and a weighted total, plus a summary on top: open pipeline, weighted forecast, won this month, win rate. Filters by owner and close date. A list view for people who think in rows.
  2. The deal sheet. Click a card and a panel opens with the value, stage, close date, owner, contact, notes and the activity history. Closing it with unsaved changes asks first.
  3. The company record. One page per client with the key facts, the contacts, the deals, an activity timeline, files and notes.

The details decide whether sales people trust it. A drop on Lost should ask for the reason, because a lost-reason report is worthless if half the reasons are empty. A deal that sits in one stage for weeks should look stale. A failed save should put the card back and say so, instead of leaving a board that lies.

Should you buy a CRM template or assemble one from blocks?

Assemble from blocks when the CRM is one part of a bigger product, run Twenty when you want a whole CRM with a backend, and buy a kit when you want many finished screens on day one. Facts from each project's public pages, as of October 2026:

OptionWhat it isStackPipeline math
Refine CRM example (opens in a new tab)Reference app: dashboard, calendar, kanban, sales pipeline, companies, contacts, quotes; MIT repoReact, Vite, Ant Design, GraphQL through Nestjs-queryStages and conversions; probability weighting not described
TheFrontKit CRM Dashboard Kit (opens in a new tab)Paid UI kit with 35+ screens, UI only, $99 (discounted from $165)Next.js 16, Tailwind CSS v4, shadcn/ui, @hello-pangea/dndColumn totals; forecast as committed, best case and pipeline
Twenty (opens in a new tab)A complete open-source CRM, mostly AGPLv3React frontend, NestJS, PostgreSQL, RedisA full product with its own backend
Wingo UI CRM blocksPro source blocks you compose into your appNext.js or Vite, React 19, Tailwind CSS v4, dnd kitWeighted totals per stage, forecast, win rate, stale deals

Twenty is the better choice when you want a CRM to run and extend, with no front end to build. A full kit wins when you want dozens of finished screens and plan to replace the seed data. Blocks fit when the CRM lives inside an agency tool, an invoicing app or a vertical SaaS and has to match the rest of it. One caveat on Refine: the README of its examples/app-crm says the full example moved to the Enterprise Edition, and the MIT repo keeps a smaller community version in examples/app-crm-minimal.

How do you lay out a Next.js CRM template?

A nextjs crm template needs two routes inside the admin route group from our Next.js admin dashboard tutorial, which also sets up the shell, the LinkProvider and the verifySession() data access layer used below.

text
app/(admin)/
layout.tsx AdminShell with layout="fill"
deals/
page.tsx loads pipelines, deals, owners, companies
deals-view.tsx client: CrmPipelinePage + actions
actions.ts moveDeal, saveDeal, createDeal, deleteDeal
clients/[id]/
page.tsx loads one company record
client-view.tsx client: CrmContactPage + actions
actions.ts saveNote, deleteNote, setTags, addDeal
server/
dal.ts verifySession
crm.ts your queries, PIPELINES, toCrmDeal

Install the two blocks and the shell. The CLI adds what they import, including the Deal Pipeline and the Kanban underneath it:

bash
npx wingo-ui@latest add crm-pipeline-page crm-contact-page admin-shell

How do you build the deal pipeline page?

Start with the data shapes. Stages live in config, because changing a probability is a product decision that should go through review like any other code change. Stage ids must be unique across pipelines: the page puts a deal in the pipeline that owns its stage.

ts
// server/crm.ts
import "server-only";
import type { CrmDeal, CrmPipeline } from "@/components/blocks/crm-pipeline-page";
export const PIPELINES: CrmPipeline[] = [
{
id: "sales",
name: "Sales",
stages: [
{ id: "qualified", name: "Qualified", probability: 0.1 },
{ id: "discovery", name: "Needs analysis", probability: 0.25 },
{ id: "proposal", name: "Proposal sent", probability: 0.5 },
{ id: "negotiation", name: "Negotiation", probability: 0.75 },
{ id: "won", name: "Won", probability: 1, kind: "won" },
{ id: "lost", name: "Lost", probability: 0, kind: "lost" },
],
},
];
export type DealRow = {
id: string;
stage_id: string;
title: string;
amount_cents: number;
currency: string;
probability: number | null;
company_id: string;
company_name: string;
owner_id: string;
owner_name: string;
close_date: string | null;
stage_entered_at: string;
lost_reason: string | null;
};
// rows from your database (Drizzle, Prisma, plain SQL) into the block's shape
export function toCrmDeal(row: DealRow): CrmDeal {
return {
id: row.id,
stageId: row.stage_id,
title: row.title,
amount: row.amount_cents / 100,
currency: row.currency,
probability: row.probability ?? undefined,
company: { name: row.company_name },
companyId: row.company_id,
owner: { id: row.owner_id, name: row.owner_name },
expectedCloseDate: row.close_date ?? undefined,
stageEnteredAt: row.stage_entered_at,
lostReason: row.lost_reason ?? undefined,
href: `/deals?deal=${row.id}`,
};
}

The page is a server component. It checks the session, loads everything in parallel and reads ?deal= so a link can open a deal's sheet. Your query helpers (listDeals, listOwners, listCompanies) return CrmDeal[], CrmOwner[] and CrmPipelineCompany[]; give each company an href such as /clients/<id> and the deal sheet links to its record.

tsx
// app/(admin)/deals/page.tsx
import { verifySession } from "@/server/dal";
import { PIPELINES, listCompanies, listDeals, listOwners } from "@/server/crm";
import { DealsView } from "./deals-view";
export default async function DealsPage({ searchParams }: { searchParams: Promise<{ deal?: string }> }) {
const { userId } = await verifySession();
const [{ deal }, deals, owners, companies] = await Promise.all([searchParams, listDeals(), listOwners(), listCompanies()]);
return (
<DealsView
pipelines={PIPELINES}
deals={deals}
owners={owners}
companies={companies}
currentUserId={userId}
openDeal={deal}
/>
);
}

The block is a client component, so the callbacks live in a small view. Every handler returns the Server Action's promise: the card shows a spinner while it runs, and a rejection springs it back with a toast.

Two details in the handlers are easy to miss. onDealMove also fires when someone reorders cards inside one column (move.from.id === move.to.id), so the view skips those; otherwise each reorder would reset stage_entered_at and wipe the stale hint and the won date. And onDealSave carries a note from the sheet as the newest activity entry with kind: "note". Its id is stable, so upsert notes by that id: a later save of the same deal sends the same note again.

tsx
// app/(admin)/deals/deals-view.tsx
"use client";
import {
CrmPipelinePage,
type CrmDeal,
type CrmOwner,
type CrmPipeline,
type CrmPipelineCompany,
} from "@/components/blocks/crm-pipeline-page";
import { createDeal, deleteDeal, moveDeal, saveDeal } from "./actions";
type Props = {
pipelines: CrmPipeline[];
deals: CrmDeal[];
owners: CrmOwner[];
companies: CrmPipelineCompany[];
currentUserId: string;
openDeal?: string;
};
export function DealsView({ pipelines, deals, owners, companies, currentUserId, openDeal }: Props) {
return (
<CrmPipelinePage
height="fill"
pipelines={pipelines}
defaultPipeline={pipelines[0]?.id}
defaultDeals={deals}
owners={owners}
companies={companies}
currentUserId={currentUserId}
defaultOpenDeal={openDeal}
onDealMove={(move) =>
// a reorder inside one column also lands here; skip it so stageEnteredAt stays true
move.from.id === move.to.id ? undefined : moveDeal(move.deal.id, move.to.id, move.lostReason)
}
onDealSave={(deal) => {
const latest = deal.activity?.[0];
const note = latest?.kind === "note" ? latest : undefined;
return saveDeal({
id: deal.id,
title: deal.title,
amount: deal.amount,
ownerId: deal.owner?.id,
expectedCloseDate: deal.expectedCloseDate,
note: note ? { id: note.id, text: note.description ?? "" } : undefined,
});
}}
onDealCreate={(deal) =>
createDeal({
title: deal.title,
stageId: deal.stageId,
amount: deal.amount,
companyId: deal.companyId,
ownerId: deal.owner?.id,
expectedCloseDate: deal.expectedCloseDate,
})
}
onDealDelete={(deal) => deleteDeal(deal.id)}
/>
);
}

Send ids and plain fields to the actions, never the whole deal: a Server Action is a public endpoint, so it takes only what it validates. Here is the move, the one that runs most:

ts
// app/(admin)/deals/actions.ts
"use server";
import { z } from "zod";
import { verifySession } from "@/server/dal";
import { PIPELINES, updateDeal } from "@/server/crm";
const stageIds = PIPELINES.flatMap((pipeline) => pipeline.stages.map((stage) => stage.id));
const Move = z.object({
dealId: z.string().min(1),
stageId: z.string().refine((id) => stageIds.includes(id), "Unknown stage"),
lostReason: z.string().max(200).optional(),
});
export async function moveDeal(dealId: string, stageId: string, lostReason?: string) {
const { userId } = await verifySession();
const move = Move.parse({ dealId, stageId, lostReason });
await updateDeal(userId, move.dealId, {
stage_id: move.stageId,
lost_reason: move.lostReason ?? null,
stage_entered_at: new Date().toISOString(),
});
}

saveDeal, createDeal and deleteDeal follow the same shape (saveDeal validates the optional note with z.object({ id: z.string(), text: z.string().max(5000) })); createDeal returns toCrmDeal(row) so the stored deal replaces the draft on the board. The board stays uncontrolled (defaultDeals), so there is no need to revalidate the page after each move: the optimistic state already shows the result.

Drag Online bookings to another stage: this demo fails its first save on purpose, so the card springs back with a toast. Then drag a deal to Lost and pick a reason, or click a card to open its sheet
$ npx wingo-ui@latest add crm-pipeline-page
ProCRM Pipeline Page docs

How does the weighted forecast work?

The weighted total of a stage is the sum of amount times probability over its deals. The probability comes from the stage unless the deal sets its own probability, which overrides it. Won deals count in full and lost deals as zero. With the stages above:

DealStageAmountProbabilityWeighted
Client portalQualified$42,00010% (stage)$4,200
Website redesignProposal sent$18,00050% (stage)$9,000
Support contractNegotiation$30,00060% (deal override)$18,000
Open pipeline$90,000$31,200

The summary has four tiles: open pipeline, weighted forecast, won in the period (the current month by default, dated by stageEnteredAt) and the win rate, counted as won deals over won plus lost in that period. On the Deal Pipeline and PipelineSummary, showWeighted={false} puts the average open deal in place of the weighted tile. Deals in another currency are summed apart with a note and never converted, which is the only honest default without an exchange rate you control.

The stage probabilities are the weak link. Set them from your own closed deals (of the deals that reached Proposal sent, how many were won?) and revisit them every quarter. Treat the numbers above as placeholders until your own history replaces them.

To put the same forecast on the dashboard home, render the summary on its own. It is a client component that takes plain data, so a server page can render it directly. For tiles of your own, computePipelineStats(deals, stages) from the same file returns the numbers behind the summary. The file starts with "use client", so call it from client code: a server component that imports it gets a client reference, not a function.

tsx
// app/(admin)/page.tsx (excerpt)
import { PipelineSummary } from "@/components/ui/deal-pipeline";
import { PIPELINES, listDeals } from "@/server/crm";
export default async function HomePage() {
const deals = await listDeals();
return <PipelineSummary deals={deals} stages={PIPELINES[0].stages} currency="USD" locale="en-US" />;
}

How do you build the company record page?

The CRM Contact Page takes one record: the company, its contacts, deals, activity, files and notes. It falls back to sample data for anything you leave out, so pass every part, including people and currentUser.

tsx
// app/(admin)/clients/[id]/page.tsx
import { notFound } from "next/navigation";
import { verifySession } from "@/server/dal";
import { PIPELINES, getClientRecord, getTeam } from "@/server/crm";
import { ClientView } from "./client-view";
export default async function ClientPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { userId } = await verifySession();
const [record, team] = await Promise.all([getClientRecord(id), getTeam()]);
if (!record) notFound();
const me = team.find((person) => person.id === userId);
return <ClientView record={record} stages={PIPELINES[0].stages} team={team} me={me} />;
}

getClientRecord returns a CrmContactSample ({ company, contacts, deals, activity, files, notes }) plus its position in the client list. Give each deal href: "/deals?deal=<id>" and a row in the Deals tab opens that deal's sheet on the board.

tsx
// app/(admin)/clients/[id]/client-view.tsx
"use client";
import type { CommentAuthor } from "@/components/ui/comment-thread";
import { CrmContactPage, type CrmContactSample, type CrmDealStage } from "@/components/blocks/crm-contact-page";
import { addDeal, deleteNote, saveNote, setTags } from "./actions";
type Props = {
record: CrmContactSample & { index: number; total: number; previousId?: string; nextId?: string };
stages: CrmDealStage[];
team: CommentAuthor[];
me?: CommentAuthor;
};
export function ClientView({ record, stages, team, me }: Props) {
const { company, index, total, previousId, nextId, ...parts } = record;
return (
<CrmContactPage
{...parts}
company={company}
stages={stages}
people={team}
currentUser={me}
pager={{
index,
total,
previousHref: previousId ? `/clients/${previousId}` : undefined,
nextHref: nextId ? `/clients/${nextId}` : undefined,
}}
previousShortcut="alt+arrowleft"
nextShortcut="alt+arrowright"
listHref="/clients"
invoiceHref={`/invoices/new?client=${company.id}`}
contactHref={`/contacts/new?client=${company.id}`}
onNoteSave={(note) => saveNote(company.id, { id: note.id, content: note.content, pinned: note.pinned })}
onNoteDelete={(note) => deleteNote(company.id, note.id)}
onTagsChange={(tags) => setTags(company.id, tags)}
onDealCreate={(values) => addDeal(company.id, values)}
/>
);
}

Two things to handle on the server. Notes arrive as HTML from the rich text editor, so sanitize them before you store or render them. And pass onUpload once you have file storage, because without it the Files tab runs a pretend upload.

Open the Deals tab, then the Notes tab and save a note with Cmd + Enter. Press Alt + Right Arrow to step to the next client, or scroll down until the condensed bar keeps the name and the primary action in view
$ npx wingo-ui@latest add crm-contact-page
ProCRM Contact Page docs

How does the CRM work on a phone?

On a phone the board shows one stage at a time, drags start with a long press, and every menu, sheet and dialog opens from the bottom. In detail:

  • Board. One stage at a time at 85% of the width with the next one peeking, and a chip row to jump between stages. A deal lifts after a 250ms long press, so a swipe still scrolls. The ⋯ menu (Move to, Mark as won, Mark as lost) opens as a bottom sheet.
  • Summary. A 2 x 2 grid that folds to one line with a tap on its chevron.
  • Deal sheet and dialogs. The sheet becomes a tall bottom sheet, the pipeline switcher and the lost-reason dialog open from the bottom, and New deal becomes a + button.
  • Company page. The header actions fold into ⋯, the side panel becomes a Details tab, and the deals table turns into cards.

The touch drag setup behind the board is explained in our dnd kit mobile touch drag tutorial, and the board itself, with WIP limits and keyboard moves, in the React kanban board guide.

Can you use the deal pipeline kanban on its own?

Yes. The CRM Pipeline Page is the full screen with the header, filters, deal sheet and new deal dialog. The Deal Pipeline is the sales pipeline UI React apps can embed anywhere: the board with the summary, the list view and the owner chips, and nothing around it. Use it for a deal pipeline kanban inside a client portal or a project page, where a whole CRM screen would be too much.

The props that matter when you use it alone:

  • stages plus defaultValue (uncontrolled) or value with onValueChange (controlled). onValueChange gets the new array and the DealMove, which is undefined for added deals and rollbacks.
  • onDealMove with the same contract as the page: return a promise to keep the card saving, reject to spring it back, return false to refuse the move.
  • onDealClick to open your own panel. Without it, a card with href follows the link.
  • persistKey to remember the view, the owner filter and the closed columns per user.
  • getDealActions for extra ⋯ menu entries after Move to, Mark as won and Mark as lost, and renderCard with the DealCard parts when the default card shows the wrong fields.
Switch to List in the toolbar to see the same deals grouped by stage, then press an owner chip and watch the summary follow the filter
$ npx wingo-ui@latest add deal-pipeline
ProDeal Pipeline docs

For a full react crm dashboard template with KPIs, tables and settings around these screens, start from the Next.js admin dashboard tutorial; the build category has the other screens step by step.

What should you build next?

Install the blocks with npx wingo-ui@latest add crm-pipeline-page crm-contact-page, point listDeals at your database and wire moveDeal first, because it is the action people use most. The CRM blocks are Pro (pricing). If you build with Claude Code or Cursor, connect the Wingo UI MCP server so the agent reads the real props of every block instead of guessing them.

Components in this post

  • CRM Pipeline Page

    A React CRM pipeline page: pipeline switcher, search and filters, a deals board or list with totals, a deal sheet and a new deal dialog.

    Pro
  • CRM Contact Page

    A React CRM contact page: a company record with facts and actions, tabs for overview, activity, deals, files and notes, and a details panel.

    Pro
  • Deal Pipeline

    A React sales pipeline board: deal stages with weighted totals, a forecast summary, drag between stages with a lost reason, a list view.

    Pro
  • Kanban

    A React kanban board for tasks, orders and pipelines: drag cards with mouse, touch or keyboard, WIP limits, inline add and a phone layout.

    Pro

FAQ

Is there a free React CRM template?

Yes. Refine publishes a CRM example built on React, Ant Design and a GraphQL API (as of October 2026 the full version moved to its Enterprise Edition, and a minimal MIT-licensed community version remains), and Twenty is a complete open-source CRM, mostly under AGPLv3, with its own backend. The Wingo UI CRM blocks in this tutorial are Pro.

How is a weighted pipeline forecast calculated?

Multiply each open deal's amount by its win probability and add the results. The probability usually comes from the deal's stage, and a deal can override it; won deals count in full and lost deals count as zero.

Where should stage probabilities come from?

From your own closed deals: for each stage, the share of deals that reached it and were later won. Start with rough guesses if you have no history, then recalibrate once a few months of closed deals exist.

Can I use a React CRM template with my own backend?

Yes, if the template is UI only. The Wingo UI blocks take data through props and report every change through callbacks such as onDealMove and onNoteSave, so they work with Server Actions, a REST API or GraphQL.

How does a sales pipeline board work on a phone?

Show one stage at a time with the next one peeking, add chips to jump between stages, and lift cards with a long press so a swipe still scrolls. Offer a Move to menu in a bottom sheet for people who do not want to drag.

  • CRM
  • Next.js
  • Sales pipeline
  • Kanban
  • Templates

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Component guides

React Kanban Board: Drag, WIP Limits, Phone Layout

Build a React kanban board that drags with mouse, touch and keyboard, enforces WIP limits, adds cards inline and shows one column at a time on a phone.

SRSerban Rusu·Oct 16, 2026·9 min read
Component guides

dnd kit Mobile: Touch Drag That Lets the Page Scroll

Fix dnd kit mobile drag: a TouchSensor long press that still lets the page scroll, touch-action per element, the iOS Safari resize cancel and a tap path.

SRSerban Rusu·Oct 9, 2026·11 min read
Build it: screens and apps

Nextjs Blog Template: Markdown, Live Demos and JSON-LD

How we built a nextjs blog template on Markdown files: zod frontmatter, live React demos, a sticky table of contents, JSON-LD, feeds and a strict validator.

SRSerban Rusu·Oct 16, 2026·10 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI