Building a Freelancer Management Platform: Technical Deep Dive
LancerSpace is an all-in-one workspace for freelancers — CRM, proposals, invoicing, and project management in a single platform. We built it from scratch, and it taught us more about multi-module architecture than any other project we have worked on.
The core challenge was not any individual feature. CRM systems, invoicing tools, and project managers all exist as standalone products. The challenge was building them as a cohesive platform where data flows naturally between modules — where a lead in the CRM becomes a proposal, a signed proposal becomes a project, and a completed project becomes an invoice, all without the user re-entering information.
Here is how we approached it.
The Multi-Module Architecture Problem
The naive approach to building a platform like LancerSpace is to treat each module as an independent feature. Build a CRM. Build an invoicing tool. Build a project manager. Connect them with some shared database tables.
We tried something close to that initially, and it fell apart fast. The problem is that modules in an all-in-one platform are not independent — they are deeply interconnected. A client in the CRM is the same entity as a client on an invoice. A project’s budget affects the invoice total. A proposal’s line items become the project’s deliverables.
We settled on a shared-core architecture where each module has its own business logic but shares a common data layer:
// Shared entity types that cross module boundaries
interface Client {
id: string;
name: string;
email: string;
company: string | null;
// CRM-specific fields
pipeline_stage: PipelineStage;
lead_source: string | null;
// Billing-specific fields
billing_address: Address | null;
tax_id: string | null;
payment_terms: number; // days
}
interface LineItem {
id: string;
description: string;
quantity: number;
unit_price: number;
// Used by both proposals and invoices
source_type: 'proposal' | 'invoice' | 'project';
source_id: string;
}
The key insight was that the Client type is the same everywhere, but different modules care about different fields. The CRM cares about pipeline_stage and lead_source. The invoicing module cares about billing_address and payment_terms. But they all operate on the same underlying record.
This meant we needed a database schema that could support multiple modules reading and writing to shared tables without stepping on each other.
Database Design: Shared Tables with Module-Specific Extensions
We used PostgreSQL through Supabase, and the schema design was the most important architectural decision we made. The core tables — clients, line_items, team_members — are shared across modules. Module-specific data lives in dedicated tables that reference the core:
-- Core shared table
CREATE TABLE clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id),
name TEXT NOT NULL,
email TEXT,
company TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- CRM-specific extension
CREATE TABLE crm_client_details (
client_id UUID PRIMARY KEY REFERENCES clients(id) ON DELETE CASCADE,
pipeline_stage TEXT NOT NULL DEFAULT 'lead',
lead_source TEXT,
last_contacted_at TIMESTAMPTZ,
notes TEXT,
deal_value NUMERIC(12, 2)
);
-- Billing-specific extension
CREATE TABLE billing_client_details (
client_id UUID PRIMARY KEY REFERENCES clients(id) ON DELETE CASCADE,
billing_address JSONB,
tax_id TEXT,
payment_terms INTEGER DEFAULT 30,
preferred_currency TEXT DEFAULT 'USD'
);
This pattern — a core table plus module-specific extension tables using the same primary key — gave us the best of both worlds. Modules can query their own data efficiently without loading fields they do not need. But cross-module features like “show me all invoices for this CRM lead” work naturally through foreign key joins.
Row Level Security policies in Supabase ensured that every query was scoped to the user’s workspace:
CREATE POLICY "workspace_isolation" ON clients
FOR ALL
USING (workspace_id IN (
SELECT workspace_id FROM workspace_members
WHERE user_id = auth.uid()
));

The CRM Module: Pipelines and Automation
The CRM module needed to support customizable pipelines — different freelancers organize their sales process differently. A photographer might have stages like “Inquiry, Quote Sent, Booked, Completed.” A developer might have “Lead, Discovery Call, Proposal Sent, Negotiation, Won/Lost.”
We modeled pipelines as a separate configuration table rather than hard-coding stages:
interface Pipeline {
id: string;
workspace_id: string;
name: string;
stages: PipelineStage[];
}
interface PipelineStage {
id: string;
name: string;
position: number;
color: string;
auto_actions: AutoAction[];
}
interface AutoAction {
trigger: 'enter_stage' | 'exit_stage';
action: 'send_email' | 'create_task' | 'set_reminder';
config: Record<string, unknown>;
}
The auto_actions field was a late addition that turned out to be one of the most valuable features. When a deal moves to “Proposal Sent,” the system can automatically create a follow-up reminder for three days later. When a deal moves to “Won,” it can automatically generate a project from the proposal. These automations save freelancers real time and reduce the friction of using the CRM consistently.
Proposal Generation and PDF Export
Proposals were one of the most technically interesting modules. Freelancers need to create professional-looking proposals that include project scope, pricing, timelines, and terms — then send them to clients for approval.
We built proposal templates using a structured JSON format that could render to both a web view and a PDF:
interface ProposalSection {
type: 'cover' | 'text' | 'scope' | 'pricing' | 'timeline' | 'terms';
content: Record<string, unknown>;
}
interface PricingSection {
type: 'pricing';
content: {
line_items: LineItem[];
discount?: { type: 'percentage' | 'fixed'; value: number };
tax_rate?: number;
payment_schedule: PaymentMilestone[];
};
}
interface PaymentMilestone {
description: string;
percentage: number;
due_trigger: 'on_signing' | 'on_date' | 'on_milestone';
due_value: string; // date or milestone ID
}
The PDF generation was a challenge. We tried several approaches:
-
Puppeteer/Playwright rendering — Spin up a headless browser, render the HTML proposal, and export to PDF. This produces pixel-perfect results but is slow and resource-intensive on serverless platforms.
-
Server-side PDF libraries — Use something like
@react-pdf/rendererorpdfkitto generate PDFs programmatically. Faster, but harder to match the visual quality of the web version. -
Hybrid approach — What we ended up with. We render the proposal as HTML with print-optimized CSS, then use a lightweight PDF service to convert it. The same HTML template powers both the web preview and the PDF export:
/* Print-optimized styles for PDF export */
@media print {
.proposal-page {
width: 210mm;
min-height: 297mm;
padding: 20mm;
page-break-after: always;
}
.pricing-table {
page-break-inside: avoid;
}
.proposal-footer {
position: fixed;
bottom: 10mm;
left: 20mm;
right: 20mm;
font-size: 9pt;
color: #666;
}
}
The page-break-inside: avoid rule on pricing tables was critical — nobody wants their pricing split across two pages. These small details in PDF generation are what separate a professional tool from a toy.
Invoicing and Payment Processing
The invoicing module connects to the proposal and project modules. When a project milestone is completed, the system can auto-generate an invoice based on the payment schedule defined in the original proposal.
Payment processing uses Stripe, with support for multiple currencies and payment methods:
async function createInvoicePayment(invoice: Invoice) {
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(invoice.total * 100), // cents
currency: invoice.currency.toLowerCase(),
metadata: {
invoice_id: invoice.id,
workspace_id: invoice.workspace_id,
client_id: invoice.client_id,
},
// Auto-calculate tax if applicable
automatic_payment_methods: { enabled: true },
});
// Store payment intent reference
await supabase
.from('invoice_payments')
.insert({
invoice_id: invoice.id,
stripe_payment_intent_id: paymentIntent.id,
amount: invoice.total,
currency: invoice.currency,
status: 'pending',
});
return paymentIntent.client_secret;
}
Invoice numbering was deceptively complex. Freelancers have strong opinions about their invoice number format — some want INV-001, others want 2026-0001, others want COMPANY-2026-03-001. We built a configurable numbering system:
interface InvoiceNumberConfig {
prefix: string; // "INV", "ACME", etc.
separator: string; // "-", "/", ""
include_year: boolean;
include_month: boolean;
padding: number; // zero-padding for sequential number
next_number: number;
}
function generateInvoiceNumber(config: InvoiceNumberConfig): string {
const parts: string[] = [];
if (config.prefix) parts.push(config.prefix);
if (config.include_year) parts.push(new Date().getFullYear().toString());
if (config.include_month) {
parts.push(String(new Date().getMonth() + 1).padStart(2, '0'));
}
parts.push(String(config.next_number).padStart(config.padding, '0'));
return parts.join(config.separator);
}

Team Permissions and Multi-Workspace Support
LancerSpace supports teams, not just solo freelancers. This meant building a permissions system that could handle different roles across different modules.
We used a role-based access control system with module-level granularity:
type Role = 'owner' | 'admin' | 'member' | 'viewer';
interface Permission {
module: 'crm' | 'proposals' | 'invoices' | 'projects';
action: 'read' | 'create' | 'update' | 'delete';
}
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
owner: [/* all permissions */],
admin: [
{ module: 'crm', action: 'read' },
{ module: 'crm', action: 'create' },
{ module: 'crm', action: 'update' },
// admins can do everything except delete in invoicing
{ module: 'invoices', action: 'read' },
{ module: 'invoices', action: 'create' },
{ module: 'invoices', action: 'update' },
// ...
],
member: [
{ module: 'crm', action: 'read' },
{ module: 'crm', action: 'create' },
{ module: 'projects', action: 'read' },
{ module: 'projects', action: 'update' },
// ...
],
viewer: [
{ module: 'crm', action: 'read' },
{ module: 'proposals', action: 'read' },
{ module: 'invoices', action: 'read' },
{ module: 'projects', action: 'read' },
],
};
These permissions are enforced at both the API layer and the database layer through Supabase RLS policies. Double enforcement is not redundant — it is a safety net. If the API layer has a bug, the database still blocks unauthorized access.
The Data Flow That Ties It All Together
The most satisfying part of LancerSpace’s architecture is how data flows between modules without user friction:
- A lead is added to the CRM (manually or via a web form)
- The freelancer moves the lead through pipeline stages, adding notes and context
- When ready, they create a proposal — the client details auto-populate from the CRM
- The client views and signs the proposal through a shared link
- Signing the proposal automatically creates a project with milestones based on the proposal’s timeline
- As project milestones are completed, invoices are auto-generated based on the payment schedule
- The client pays the invoice through an embedded payment form
- Payment status updates flow back to the project and CRM, closing the loop
Each step is automated but overridable. The freelancer can intervene at any point — editing the auto-generated invoice, adjusting project milestones, skipping steps entirely. Automation should remove tedium, not remove control.

Lessons for Multi-Module Platform Builders
If you are building a platform that combines multiple functional areas, here is what we wish we had known at the start:
Design the data model across all modules simultaneously. Do not design the CRM schema, then the invoicing schema, then try to connect them. Design them together, identifying shared entities upfront.
Build the cross-module flows early. The “lead to proposal to project to invoice” flow was the core value proposition. We should have built a rough version of it in week two, not week eight.
Invest in a shared component library. Client pickers, date pickers, currency inputs, status badges — these appear in every module. Building them once with consistent behavior saved enormous time.
Test the permissions layer obsessively. Multi-tenant bugs are the worst kind of bug. A user seeing another workspace’s data is a trust-destroying event. We wrote integration tests specifically for workspace isolation and ran them on every deploy.
For more on multi-tenant architecture patterns, see our post on multi-tenant SaaS architecture. For our broader approach to building SaaS platforms, see building a SaaS platform from scratch.
If you are building a freelancer tool, a multi-module SaaS platform, or anything that requires complex data flows between features, reach out at [email protected]. We have been through these problems and can help you avoid the mistakes we made along the way.