Building a Customer Pipeline Dashboard with React and TinyCRM
TinyCRM's built-in dashboard works well for most use cases. But sometimes you want to embed customer data directly into your own admin panel — filtered by your specific criteria, styled to match your product, or combined with data from other sources.
This tutorial shows how to build a customer activity dashboard in React using TinyCRM's REST API. We'll cover authentication, fetching customer lists, filtering by status and project, and building a simple activity feed. By the end you'll have a functional dashboard you can embed anywhere.
What you'll build
- A customer list with status badges and project tags
- Filter controls for project and status
- Sort by signup date or last activity
- A simple stats bar (total, paid, free counts)
Architecture: API route proxy pattern
You should never call TinyCRM's API directly from the browser — that would expose your session token. Instead, use a backend API route as a proxy. Your React component calls your own API route, which calls TinyCRM with auth.
Browser (React)
↓ fetch('/api/crm/customers')
Your Next.js API route (app/api/crm/customers/route.ts)
↓ fetch('https://app.tinycrm.dev/api/v1/customers', { headers: auth })
TinyCRM API
↓ returns customer data
Your API route returns sanitized data to browserThis keeps your TinyCRM session on the server side. If you're building an admin panel that's already authenticated, you can use your own session to gate access to the proxy route.
Step 1: Create the API proxy route
// app/api/crm/customers/route.ts
import { NextRequest } from 'next/server';
const TINYCRM_BASE = 'https://app.tinycrm.dev/api/v1';
// This route is called by your React dashboard.
// It proxies to TinyCRM using a server-side session cookie.
// In production, add your own auth check here.
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
// Forward query params to TinyCRM
const params = new URLSearchParams();
const project = searchParams.get('project');
const status = searchParams.get('status');
const sort = searchParams.get('sort') ?? 'created_at';
const page = searchParams.get('page') ?? '1';
if (project) params.set('project', project);
if (status) params.set('status', status);
params.set('sort', sort);
params.set('page', page);
params.set('limit', '50');
// Use a cookie-based session (dashboard auth)
const cookieHeader = request.headers.get('cookie') ?? '';
const res = await fetch(
`${TINYCRM_BASE}/customers?${params.toString()}`,
{
headers: {
Cookie: cookieHeader,
'Content-Type': 'application/json',
},
}
);
if (!res.ok) {
return Response.json(
{ error: 'Failed to fetch customers' },
{ status: res.status }
);
}
const data = await res.json();
return Response.json(data);
}Step 2: Define your TypeScript types
// types/crm.ts
export interface CustomerProject {
project_id: string;
project_name: string;
status: 'free' | 'paid';
params: Record<string, string | number | boolean>;
last_activity_at: string | null;
}
export interface Customer {
id: string;
email: string;
name: string | null;
created_at: string;
projects: CustomerProject[];
}
export interface CustomersResponse {
ok: boolean;
data: Customer[];
pagination: {
total: number;
page: number;
limit: number;
has_more: boolean;
};
}Step 3: Build the data fetching hook
// hooks/useCustomers.ts
'use client';
import { useState, useEffect } from 'react';
import type { Customer, CustomersResponse } from '@/types/crm';
interface UseCustomersOptions {
project?: string;
status?: 'free' | 'paid';
sort?: 'created_at' | 'last_activity_at';
}
export function useCustomers(options: UseCustomersOptions = {}) {
const [customers, setCustomers] = useState<Customer[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const params = new URLSearchParams();
if (options.project) params.set('project', options.project);
if (options.status) params.set('status', options.status);
if (options.sort) params.set('sort', options.sort);
setLoading(true);
fetch(`/api/crm/customers?${params.toString()}`)
.then((res) => res.json())
.then((data: CustomersResponse) => {
if (data.ok) {
setCustomers(data.data);
setTotal(data.pagination.total);
} else {
setError('Failed to load customers');
}
})
.catch(() => setError('Network error'))
.finally(() => setLoading(false));
}, [options.project, options.status, options.sort]);
return { customers, total, loading, error };
}Step 4: Build the customer table component
// components/CustomerTable.tsx
'use client';
import { useState } from 'react';
import { useCustomers } from '@/hooks/useCustomers';
import type { Customer } from '@/types/crm';
function StatusBadge({ status }: { status: 'free' | 'paid' }) {
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
status === 'paid'
? 'bg-foreground text-background'
: 'border border-border text-muted-foreground'
}`}
>
{status}
</span>
);
}
function formatRelativeDate(dateStr: string | null): string {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000);
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 30) return `${diffDays}d ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
return `${Math.floor(diffDays / 365)}y ago`;
}
export function CustomerTable() {
const [status, setStatus] = useState<'free' | 'paid' | undefined>();
const [sort, setSort] = useState<'created_at' | 'last_activity_at'>('created_at');
const { customers, total, loading, error } = useCustomers({ status, sort });
if (error) return <p className="text-destructive">{error}</p>;
return (
<div className="space-y-4">
{/* Stats bar */}
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<span>{total} total customers</span>
<span>
{customers.filter(c => c.projects.some(p => p.status === 'paid')).length} paid
</span>
</div>
{/* Filters */}
<div className="flex gap-2">
<select
value={status ?? ''}
onChange={(e) => setStatus(e.target.value as 'free' | 'paid' | undefined || undefined)}
className="rounded border border-border bg-background px-3 py-1.5 text-sm"
>
<option value="">All statuses</option>
<option value="paid">Paid</option>
<option value="free">Free</option>
</select>
<select
value={sort}
onChange={(e) => setSort(e.target.value as 'created_at' | 'last_activity_at')}
className="rounded border border-border bg-background px-3 py-1.5 text-sm"
>
<option value="created_at">Newest first</option>
<option value="last_activity_at">Recently active</option>
</select>
</div>
{/* Table */}
{loading ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded bg-muted" />
))}
</div>
) : (
<div className="rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Customer</th>
<th className="px-4 py-3 text-left font-medium">Projects</th>
<th className="px-4 py-3 text-left font-medium">Last Active</th>
<th className="px-4 py-3 text-left font-medium">Joined</th>
</tr>
</thead>
<tbody>
{customers.map((customer: Customer) => (
<tr key={customer.id} className="border-b border-border last:border-0">
<td className="px-4 py-3">
<div>
<p className="font-medium">{customer.name ?? customer.email}</p>
{customer.name && (
<p className="text-xs text-muted-foreground">{customer.email}</p>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{customer.projects.map((cp) => (
<div key={cp.project_id} className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">{cp.project_name}</span>
<StatusBadge status={cp.status} />
</div>
))}
</div>
</td>
<td className="px-4 py-3 text-muted-foreground">
{formatRelativeDate(
customer.projects
.map(p => p.last_activity_at)
.filter(Boolean)
.sort()
.at(-1) ?? null
)}
</td>
<td className="px-4 py-3 text-muted-foreground">
{formatRelativeDate(customer.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}Step 5: Use it in your admin page
// app/admin/customers/page.tsx
import { CustomerTable } from '@/components/CustomerTable';
export default function AdminCustomersPage() {
return (
<div className="p-8">
<h1 className="mb-6 text-2xl font-bold">Customers</h1>
<CustomerTable />
</div>
);
}Extending the dashboard
With this foundation, you can extend in several directions:
- Add a stats panel: Total MRR, churn count, new signups this week — all derivable from the customer list.
- Add search: Filter by email or name using a search input and query param.
- Add pagination: Pass a page param and render navigation controls.
- Add a customer detail view: Click a row to navigate to a detail page that fetches
/api/v1/customers/[id].
See the full API reference for all available query parameters and response shapes.
Build on TinyCRM's API
TinyCRM exposes a full REST API for reading customer data. Use it to build custom dashboards, reports, or integrations.