<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Eduardo Saavedra</title>
    <description>The latest articles on DEV Community by Eduardo Saavedra (@eduar766).</description>
    <link>https://dev.to/eduar766</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F518353%2F4ca3adc2-d030-4bb3-af71-d1832244843f.jpeg</url>
      <title>DEV Community: Eduardo Saavedra</title>
      <link>https://dev.to/eduar766</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eduar766"/>
    <language>en</language>
    <item>
      <title>Building a CRM for Freelancers: Architecture Decisions Behind Lazy CRM</title>
      <dc:creator>Eduardo Saavedra</dc:creator>
      <pubDate>Sun, 15 Feb 2026 18:41:29 +0000</pubDate>
      <link>https://dev.to/eduar766/building-a-crm-for-freelancers-architecture-decisions-behind-lazy-crm-19m</link>
      <guid>https://dev.to/eduar766/building-a-crm-for-freelancers-architecture-decisions-behind-lazy-crm-19m</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Having worked as a freelancer, I knew exactly what was missing. So I built &lt;a href="https://www.lazy-crm.com" rel="noopener noreferrer"&gt;Lazy CRM&lt;/a&gt; — 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Project Structure
&lt;/h2&gt;

&lt;p&gt;The project is split into three independent applications inside a single repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I want to be upfront: this is not a monorepo. There's no root &lt;code&gt;package.json&lt;/code&gt; with workspaces, no Turborepo or Nx orchestrating builds, no shared type packages. Each project has its own &lt;code&gt;node_modules&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Backend: NestJS + Prisma + Neon DB
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why NestJS
&lt;/h3&gt;

&lt;p&gt;I went with NestJS because it provides structure without getting in the way. The module system naturally maps to business domains:&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Prisma + Neon DB
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  PDF Generation &amp;amp; Storage
&lt;/h3&gt;

&lt;p&gt;Invoices need to become PDFs. The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User creates an invoice through the wizard (select lead → add line items → preview)&lt;/li&gt;
&lt;li&gt;Backend generates the PDF server-side&lt;/li&gt;
&lt;li&gt;PDF gets uploaded to cloud storage&lt;/li&gt;
&lt;li&gt;User can download or share the invoice&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I chose a storage provider with no egress fees, which matters when users repeatedly download or share their invoices.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frontend: React 19 + Vite + TypeScript
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Stack
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;Vite (fast HMR, optimized production builds)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;Tailwind CSS (utility-first, no CSS modules)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server State&lt;/td&gt;
&lt;td&gt;TanStack Query (caching, refetching, optimistic updates)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client State&lt;/td&gt;
&lt;td&gt;Zustand (minimal global state, mainly for the invoice wizard)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forms&lt;/td&gt;
&lt;td&gt;react-hook-form + Zod (schema-based validation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drag &amp;amp; Drop&lt;/td&gt;
&lt;td&gt;@dnd-kit (accessible, performant)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Icons&lt;/td&gt;
&lt;td&gt;lucide-react&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Routing&lt;/td&gt;
&lt;td&gt;react-router-dom v7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;i18n&lt;/td&gt;
&lt;td&gt;react-i18next&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why TanStack Query + Zustand (Not Redux)
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Redux would work, but it would also mean writing action creators, reducers, and middleware for problems that TanStack Query already solves better.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Leads Pipeline (Drag &amp;amp; Drop)
&lt;/h3&gt;

&lt;p&gt;The leads page is a Kanban board with four columns: &lt;strong&gt;New&lt;/strong&gt;, &lt;strong&gt;Negotiation&lt;/strong&gt;, &lt;strong&gt;Won&lt;/strong&gt;, and &lt;strong&gt;Lost&lt;/strong&gt;. Users drag leads between columns to update their status.&lt;/p&gt;

&lt;p&gt;I used &lt;code&gt;@dnd-kit&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Form Validation with Zod + i18n
&lt;/h3&gt;

&lt;p&gt;Here's a pattern I'm particularly happy with. Zod schemas are defined inside components using &lt;code&gt;useMemo&lt;/code&gt;, so they can use translated error messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;validation.emailRequired&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;validation.passwordMin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means validation messages automatically switch language when the user toggles between English and Spanish — no extra wiring needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Internationalization: Two Different Strategies
&lt;/h2&gt;

&lt;p&gt;The landing page and the CRM app have different i18n needs, so they use different solutions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Landing: next-intl (URL-based routing)
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  CRM App: react-i18next (Client-side detection)
&lt;/h3&gt;

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

&lt;p&gt;The translation files are flat JSON organized by feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/locales/
├── en/translation.json  (~300 strings)
└── es/translation.json  (~300 strings)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few patterns emerged during the i18n migration that kept things consistent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Month keys as arrays:&lt;/strong&gt; &lt;code&gt;MONTH_KEYS = ['months.january', ...]&lt;/code&gt; → &lt;code&gt;t(MONTH_KEYS[index])&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status label maps:&lt;/strong&gt; &lt;code&gt;STATUS_LABEL_KEYS: Record&amp;lt;Status, string&amp;gt;&lt;/code&gt; → &lt;code&gt;t(STATUS_LABEL_KEYS[status])&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zod in useMemo:&lt;/strong&gt; Schemas inside components so &lt;code&gt;t()&lt;/code&gt; is available for validation messages&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Webhooks: Receiving Leads from External Sources
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User gets a unique, authenticated webhook URL from Settings&lt;/li&gt;
&lt;li&gt;External source sends a &lt;code&gt;POST&lt;/code&gt; with the lead details&lt;/li&gt;
&lt;li&gt;Backend validates the payload, matches or creates the client, and creates the lead&lt;/li&gt;
&lt;li&gt;Lead appears in the pipeline automatically&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Settings page includes integration documentation with examples for common tools like Zapier, Make, and HTML forms.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;End-to-end types.&lt;/strong&gt; In a larger project, I'd use something like &lt;code&gt;ts-rest&lt;/code&gt; or generate types from the OpenAPI spec to keep frontend and backend contracts in sync at compile time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proper monorepo setup.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Stack at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Landing&lt;/td&gt;
&lt;td&gt;Next.js 16, React 19, Tailwind CSS, next-intl&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;React 19, Vite, TypeScript, Tailwind CSS, TanStack Query, Zustand, react-i18next, react-hook-form, Zod, @dnd-kit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;NestJS, Prisma ORM, Passport-JWT (custom auth + Google/GitHub OAuth)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure&lt;/td&gt;
&lt;td&gt;Serverless PostgreSQL, Cloud object storage (PDFs), Transactional email service&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;If you're a freelancer tired of overcomplicated tools, give &lt;a href="https://lazy-crm.com/en" rel="noopener noreferrer"&gt;Lazy CRM&lt;/a&gt; a try — it's free. And if you have questions about any of the architecture decisions, drop them in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nestjs</category>
      <category>react</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
