Tools
Tools: Building a CRM for Freelancers: Architecture Decisions Behind Lazy CRM
2026-02-15
0 views
admin
The Project Structure ## Backend: NestJS + Prisma + Neon DB ## Why NestJS ## Why Prisma + Neon DB ## PDF Generation & Storage ## Frontend: React 19 + Vite + TypeScript ## The Stack ## Why TanStack Query + Zustand (Not Redux) ## The Leads Pipeline (Drag & Drop) ## Form Validation with Zod + i18n ## Internationalization: Two Different Strategies ## Landing: next-intl (URL-based routing) ## CRM App: react-i18next (Client-side detection) ## Webhooks: Receiving Leads from External Sources ## What I'd Do Differently ## The Stack at a Glance 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 is split into three independent applications inside a single repository: 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. 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. 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. Invoices need to become PDFs. The flow: I chose a storage provider with no egress fees, which matters when users repeatedly download or share their invoices. 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 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. Here's a pattern I'm particularly happy with. Zod schemas are defined inside components using useMemo, so they can use translated error messages: This means validation messages automatically switch language when the user toggles between English and Spanish — no extra wiring needed. The landing page and the CRM app have different i18n needs, so they use different solutions. 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. 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: A few patterns emerged during the i18n migration that kept things consistent: 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 Settings page includes integration documentation with examples for common tools like Zapier, Make, and HTML forms. 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. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
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 CODE_BLOCK:
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) CODE_BLOCK:
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) COMMAND_BLOCK:
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 COMMAND_BLOCK:
const schema = useMemo(() => z.object({ email: z.string().email(t('validation.emailRequired')), password: z.string().min(6, t('validation.passwordMin')),
}), [t]); COMMAND_BLOCK:
const schema = useMemo(() => z.object({ email: z.string().email(t('validation.emailRequired')), password: z.string().min(6, t('validation.passwordMin')),
}), [t]); CODE_BLOCK:
src/locales/
├── en/translation.json (~300 strings)
└── es/translation.json (~300 strings) Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
src/locales/
├── en/translation.json (~300 strings)
└── es/translation.json (~300 strings) CODE_BLOCK:
src/locales/
├── en/translation.json (~300 strings)
└── es/translation.json (~300 strings) - User creates an invoice through the wizard (select lead → add line items → preview)
- Backend generates the PDF server-side
- PDF gets uploaded to cloud storage
- User can download or share the invoice - 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 - User gets a unique, authenticated webhook URL from Settings
- External source sends a POST with the lead details
- Backend validates the payload, matches or creates the client, and creates the lead
- Lead appears in the pipeline automatically - 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.
how-totutorialguidedev.toaimlserverroutingswitchpostgresqlnodedatabasegitgithub