DEV Community

Cover image for Building a CRM for Freelancers: Architecture Decisions Behind Lazy CRM
Eduardo Saavedra
Eduardo Saavedra

Posted on

Building a CRM for Freelancers: Architecture Decisions Behind Lazy CRM

Most CRMs are built for sales teams of 50+. They come with dashboards you'll never use, integrations you don't need, and a learning curve that makes you wonder if you should've just kept using a spreadsheet.

Having worked as a freelancer, I knew exactly what was missing. So I built Lazy CRM — a minimalist CRM designed for people who work alone or in very small teams. In this post, I'll walk through the architecture, the tech decisions, and the trade-offs I made along the way.


The Project Structure

The project is split into three independent applications inside a single repository:

lazy-crm/
├── lazy-crm-landing/    → Public landing page (Next.js 16 + next-intl)
├── lazy-crm-front/      → Main CRM app (React 19 + Vite + TypeScript)
└── lazy-crm-services/   → Backend API (NestJS + Prisma + Neon DB)
Enter fullscreen mode Exit fullscreen mode

I want to be upfront: this is not a monorepo. There's no root package.json with workspaces, no Turborepo or Nx orchestrating builds, no shared type packages. Each project has its own node_modules and builds independently. If I change a DTO in the backend, the frontend won't know until runtime. It's three co-located projects sharing a git repository.

Why this approach instead of a proper monorepo? Honestly, pragmatism. Setting up shared packages and build orchestration is overhead that doesn't pay off at this scale. The trade-off is real — I've had a couple of mismatches between API responses and frontend types — but for a solo project, the simplicity wins.

Why not a full-stack framework like Next.js for everything? Because the landing page and the app have fundamentally different needs. The landing is static, SEO-critical, and benefits from SSR/SSG. The CRM app is a fully interactive SPA — forms, drag-and-drop, real-time state — where client-side rendering makes more sense.


Backend: NestJS + Prisma + Neon DB

Why NestJS

I went with NestJS because it provides structure without getting in the way. The module system naturally maps to business domains:

The backend is organized into domain modules: authentication, client management, lead pipeline, invoicing (with PDF generation and email delivery), dashboard stats, historical performance, revenue goals, file storage, and transactional emails.

Each module follows the same internal pattern: application layer (services), infrastructure layer (controllers, DTOs), and domain layer when needed. It's not full DDD — that would be overkill for this scale — but the separation keeps things navigable.

Why Prisma + Neon DB

Neon is a serverless PostgreSQL provider — I get a managed database that scales to zero when idle and spins up instantly when needed. No provisioning, no fixed costs for a side project.

Authentication uses Passport-JWT with support for email/password and Google/GitHub OAuth. NestJS guards handle token validation on every request.

Prisma sits on top of the database. The type-safe client means I catch schema mismatches at compile time, not at runtime. Migrations are straightforward, and the schema file serves as living documentation of the data model.

PDF Generation & Storage

Invoices need to become PDFs. The flow:

  1. User creates an invoice through the wizard (select lead → add line items → preview)
  2. Backend generates the PDF server-side
  3. PDF gets uploaded to cloud storage
  4. User can download or share the invoice

I chose a storage provider with no egress fees, which matters when users repeatedly download or share their invoices.


Frontend: React 19 + Vite + TypeScript

The Stack

Layer Tool
Build Vite (fast HMR, optimized production builds)
UI Tailwind CSS (utility-first, no CSS modules)
Server State TanStack Query (caching, refetching, optimistic updates)
Client State Zustand (minimal global state, mainly for the invoice wizard)
Forms react-hook-form + Zod (schema-based validation)
Drag & Drop @dnd-kit (accessible, performant)
Icons lucide-react
Routing react-router-dom v7
i18n react-i18next

Why TanStack Query + Zustand (Not Redux)

This is a question I get asked. The answer is simple: most of the app's state lives on the server. Client lists, leads, invoices, dashboard stats — all of it comes from the API. TanStack Query handles that perfectly: caching, background refetching, loading/error states, all built-in.

Zustand handles the small amount of truly client-side state: the invoice wizard's multi-step form data and a few UI preferences. It's about 20 lines of store code total.

Redux would work, but it would also mean writing action creators, reducers, and middleware for problems that TanStack Query already solves better.

The Leads Pipeline (Drag & Drop)

The leads page is a Kanban board with four columns: New, Negotiation, Won, and Lost. Users drag leads between columns to update their status.

I used @dnd-kit because it handles accessibility (keyboard navigation, screen reader announcements) out of the box. When a lead is dropped into a new column, an optimistic update fires immediately — the UI moves the card, and the API call happens in the background. If the API fails, TanStack Query rolls it back.

Form Validation with Zod + i18n

Here's a pattern I'm particularly happy with. Zod schemas are defined inside components using useMemo, so they can use translated error messages:

const schema = useMemo(() => z.object({
  email: z.string().email(t('validation.emailRequired')),
  password: z.string().min(6, t('validation.passwordMin')),
}), [t]);
Enter fullscreen mode Exit fullscreen mode

This means validation messages automatically switch language when the user toggles between English and Spanish — no extra wiring needed.


Internationalization: Two Different Strategies

The landing page and the CRM app have different i18n needs, so they use different solutions.

Landing: next-intl (URL-based routing)

The landing page uses next-intl with locale-based URL routing (/en/, /es/). This is critical for SEO — search engines see separate URLs for each language, with proper hreflang tags, OpenGraph locale metadata, and Schema.org inLanguage attributes.

CRM App: react-i18next (Client-side detection)

The app uses react-i18next with i18next-browser-languagedetector. It detects the browser's language on first visit and persists the choice in localStorage. There's a globe icon in the sidebar to toggle manually.

The translation files are flat JSON organized by feature:

src/locales/
├── en/translation.json  (~300 strings)
└── es/translation.json  (~300 strings)
Enter fullscreen mode Exit fullscreen mode

A few patterns emerged during the i18n migration that kept things consistent:

  • Month keys as arrays: MONTH_KEYS = ['months.january', ...]t(MONTH_KEYS[index])
  • Status label maps: STATUS_LABEL_KEYS: Record<Status, string>t(STATUS_LABEL_KEYS[status])
  • Zod in useMemo: Schemas inside components so t() is available for validation messages

Webhooks: Receiving Leads from External Sources

Freelancers often have landing pages, contact forms, or Zapier automations that generate leads. Lazy CRM provides each user with a unique webhook URL they can use to push leads into their pipeline automatically.

The flow:

  1. User gets a unique, authenticated webhook URL from Settings
  2. External source sends a POST with the lead details
  3. Backend validates the payload, matches or creates the client, and creates the lead
  4. Lead appears in the pipeline automatically

The Settings page includes integration documentation with examples for common tools like Zapier, Make, and HTML forms.


What I'd Do Differently

  • End-to-end types. In a larger project, I'd use something like ts-rest or generate types from the OpenAPI spec to keep frontend and backend contracts in sync at compile time.
  • Proper monorepo setup. The three projects are co-located but fully independent. Adding workspaces, shared type packages, and build orchestration (Turborepo/Nx) would improve the developer experience as the team grows.

The Stack at a Glance

Layer Technology
Landing Next.js 16, React 19, Tailwind CSS, next-intl
Frontend React 19, Vite, TypeScript, Tailwind CSS, TanStack Query, Zustand, react-i18next, react-hook-form, Zod, @dnd-kit
Backend NestJS, Prisma ORM, Passport-JWT (custom auth + Google/GitHub OAuth)
Infrastructure Serverless PostgreSQL, Cloud object storage (PDFs), Transactional email service

If you're a freelancer tired of overcomplicated tools, give Lazy CRM a try — it's free. And if you have questions about any of the architecture decisions, drop them in the comments.

Top comments (6)

Collapse
 
egedev profile image
egeindie

As a freelancer myself, I feel this pain deeply. I have tried Notion, spreadsheets, even a custom Airtable setup - none of them stuck because they all required too much manual input.

The architecture choices here are solid. Going with a simple stack instead of over-engineering is exactly the right move for a solo-dev product. I made the same decision with my own SaaS projects (Go + React + Postgres) and it pays off in deployment simplicity and maintenance.

Curious about your approach to client communication tracking - do you pull from email/calendar automatically or is it manual entry?

Collapse
 
eduar766 profile image
Eduardo Saavedra

For now, it’s manual. Leads come from direct creation or through webhooks (contact forms, Zapier, etc.). There is no email or calendar integration yet.

I have thought about it. It could be a great feature, but I’m not sure if it would make the system more complex than necessary. Thank you very much for reading.

Collapse
 
theminimalcreator profile image
Guilherme Zaia

I appreciate the pragmatic approach here! Creating independent apps within a single repository can definitely simplify things for small-scale projects. With minimal overhead and focused builds, it's a solid way to keep flexibility without the monorepo complexity. Have you encountered any significant challenges managing version mismatches between the front and back ends? 🚀

Collapse
 
eduar766 profile image
Eduardo Saavedra

Thanks! Honestly, it hasn't been a major issue so far... the API surface is small enough that mismatches are rare and easy to catch during development. For a larger team or a growing API, I'd move to shared type generation to catch those at compile time.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.