# React CRM Template: Pipeline, Forecast, Company Pages

> Build a React CRM template in Next.js from blocks: a deal pipeline kanban with weighted forecasts, a deal sheet, company record pages and a phone layout.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Build it: screens and apps](https://wingo-ui.com/blog/category/build)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/react-crm-template

## 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.

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:

| Option | What it is | Stack | Pipeline math |
|---|---|---|---|
| [Refine CRM example](https://refine.dev/templates/crm-application/) | Reference app: dashboard, calendar, kanban, sales pipeline, companies, contacts, quotes; MIT repo | React, Vite, Ant Design, GraphQL through Nestjs-query | Stages and conversions; probability weighting not described |
| [TheFrontKit CRM Dashboard Kit](https://thefrontkit.com/crm-dashboard-template) | Paid UI kit with 35+ screens, UI only, $99 (discounted from $165) | Next.js 16, Tailwind CSS v4, shadcn/ui, @hello-pangea/dnd | Column totals; forecast as committed, best case and pipeline |
| [Twenty](https://github.com/twentyhq/twenty) | A complete open-source CRM, mostly AGPLv3 | React frontend, NestJS, PostgreSQL, Redis | A full product with its own backend |
| [Wingo UI CRM blocks](https://wingo-ui.com/components/crm-pipeline-page) | Pro source blocks you compose into your app | Next.js or Vite, React 19, Tailwind CSS v4, dnd kit | Weighted 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](https://wingo-ui.com/blog/nextjs-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](https://wingo-ui.com/components/deal-pipeline) and the [Kanban](https://wingo-ui.com/components/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.

> Live demo (CRM Pipeline Page): 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. Try it at [CRM Pipeline Page](https://wingo-ui.com/components/crm-pipeline-page) and install it with `npx wingo-ui@latest add crm-pipeline-page`.

### 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:

| Deal | Stage | Amount | Probability | Weighted |
|---|---|---|---|---|
| Client portal | Qualified | $42,000 | 10% (stage) | $4,200 |
| Website redesign | Proposal sent | $18,000 | 50% (stage) | $9,000 |
| Support contract | Negotiation | $30,000 | 60% (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](https://wingo-ui.com/components/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.

> Live demo (CRM Contact Page): 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. Try it at [CRM Contact Page](https://wingo-ui.com/components/crm-contact-page) and install it with `npx wingo-ui@latest add crm-contact-page`.

## 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](https://wingo-ui.com/blog/dnd-kit-mobile-touch-drag), and the board itself, with WIP limits and keyboard moves, in the [React kanban board guide](https://wingo-ui.com/blog/react-kanban-board).

## 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.

> Live demo (Deal Pipeline): 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. Try it at [Deal Pipeline](https://wingo-ui.com/components/deal-pipeline) and install it with `npx wingo-ui@latest add deal-pipeline`.

For a full react crm dashboard template with KPIs, tables and settings around these screens, start from the [Next.js admin dashboard tutorial](https://wingo-ui.com/blog/nextjs-admin-dashboard-tutorial); the [build category](https://wingo-ui.com/blog/category/build) 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](https://wingo-ui.com/pricing)). If you build with Claude Code or Cursor, connect the [Wingo UI MCP server](https://wingo-ui.com/docs/mcp) so the agent reads the real props of every block instead of guessing them.

## Components in this post

- [CRM Pipeline Page](https://wingo-ui.com/components/crm-pipeline-page) (Pro): 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. Install: `npx wingo-ui@latest add crm-pipeline-page`
- [CRM Contact Page](https://wingo-ui.com/components/crm-contact-page) (Pro): A React CRM contact page: a company record with facts and actions, tabs for overview, activity, deals, files and notes, and a details panel. Install: `npx wingo-ui@latest add crm-contact-page`
- [Deal Pipeline](https://wingo-ui.com/components/deal-pipeline) (Pro): A React sales pipeline board: deal stages with weighted totals, a forecast summary, drag between stages with a lost reason, a list view. Install: `npx wingo-ui@latest add deal-pipeline`
- [Kanban](https://wingo-ui.com/components/kanban) (Pro): A React kanban board for tasks, orders and pipelines: drag cards with mouse, touch or keyboard, WIP limits, inline add and a phone layout. Install: `npx wingo-ui@latest add kanban`

## 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.

---

Source: https://wingo-ui.com/blog/react-crm-template
