Skip to content

Marketing · Email · Dashboards

Email remarketing without the Mailchimp tax: visual node flows you own

All articles
✉️ 🔀 🛠

React Flow, JSONB, and the executor we deliberately didn't ship yet

Every marketing-tool review post on the internet recommends the same five products: Mailchimp, Klaviyo, ConvertKit, ActiveCampaign, Brevo. They are all priced per contact and per channel and per feature, and the price scales linearly with success — the better your business gets, the more you pay them. A growing SaaS clinic we work with was paying eight hundred dollars a month for an ActiveCampaign account that ran three actual flows, each with five nodes. We did the maths. They were paying around fifty-three dollars per node per month for software they didn't own.

The tool sprawl problem

The deeper issue isn't the per-contact pricing. The deeper issue is that the email tool is a separate service from the CRM, which is a separate service from the website, which is a separate service from the analytics. Each one has its own login, its own audience definition, its own attribution model. When the client wants to email "everyone who booked a discovery call in the last fourteen days but hasn't paid yet", they have to manually export from one system, clean the CSV in Excel, import to the other, schedule the send, and then go back to the first system to see who opened.

We've watched marketing people spend forty per cent of their week being a human ETL pipeline between SaaS tools. That's the actual cost of the stack. The eight hundred dollars is just the part you see on the invoice. The labour cost of integration is invisible until you sit down and count the hours.

A node graph is the right abstraction

Every email remarketing tool eventually converges on the same UI: a canvas with nodes connected by lines. Audience → wait → send → branch on the event → wait → send. There's a reason this pattern wins: a flow is fundamentally a state machine, and humans understand state machines better when they can see the boxes and arrows. Pasting trigger conditions and delay periods into a list form, like older tools used to, makes it impossible to mentally simulate what happens to a contact who doesn't open the second email.

So our remarketing surface is a canvas. The user drags nodes from a left palette, wires them together by dragging from the bottom port of one to the top port of the next, and edits each node's properties in a right inspector. Four node kinds: audience, wait, send-email, and branch. The whole flow is a directed graph with one entry point and any number of leaf nodes, and the runtime executor (which we're holding off on until V2) walks the graph per contact.

React Flow, not Drawflow, not custom

There are three serious choices for embedding a node-graph editor in a React app. Drawflow is the lightweight option — small bundle, decent UX, but no preserve-3D or transform-style tricks because it doesn't use real DOM nodes for the boxes. React Flow (now @xyflow/react) is the heavyweight — large bundle but extensive features and a real DOM-based rendering model that respects your CSS. And there's the custom route, where you write your own pan/zoom/drag/connect machinery from scratch.

We picked React Flow. The bundle hit is real (~80 KB gzipped), but the alternative was three weeks of writing our own bezier-curve renderer and our own port collision logic, and we'd still ship worse software than a team that's been iterating on this problem for five years. The cost-benefit is one-sided: pay the bytes, save the months. We use less than a third of the API surface — just nodes, edges, the connect handler, the keyboard shortcuts, and the minimap.

Why graphs live in a single JSONB column

The classic relational way to store a graph is two tables: flow_nodes and flow_edges. We could do that. We didn't. The whole graph for one flow lives in a single graph JSONB column on remarketing_flows, alongside the flow's name, status, and trigger config. The shape is { nodes: [...], edges: [...] }, which is exactly what React Flow's useNodesState hook produces.

The advantage is round-trip simplicity. The editor reads the JSONB on mount, lets the user manipulate it via React Flow, and writes the whole thing back on save. No joins, no foreign-key cascade headaches, no edge-orphans-when-a-node-deletes problem. The downside is that you can't run "show me all flows that send an email to audience X" as a SQL query without scanning every JSONB. We're fine with that — the analytics on flows live elsewhere (per-send rows in a separate table), and the editor itself doesn't need cross-flow indexes.

If we hit a real scale problem we'll denormalise into proper tables. We probably won't.

The four node kinds we ship in V1

We sat down at the start of this build and tried to enumerate every node kind the user might want. We came up with about twenty. Then we picked the four that solve eighty per cent of actual remarketing campaigns: audience, wait, send-email, and branch. Anything more specialised — A/B split testing, SMS sends, webhook fire-and-forget, conditional segments — can be modeled as a combination of those four.

Audience filters a contact into the flow based on a segment name (V1 is just a free-text segment label that V2 will tie to a real CRM segment query). Wait pauses the flow for n minutes, hours, or days. Send-email composes the message and ships it (V2 — V1 just stores the subject/body for review). Branch splits on an event: opened, clicked, or replied within the wait window. That's it. The graph for "if they don't open the first email, try a different subject line three days later" is five nodes. The user can build it in under two minutes.

RLS, role gating, and why we trust nothing client-side

The remarketing builder lives under /dashboard/admin/remarketing, gated by Supabase Row Level Security to users with role in ('admin','marketing','slt'). The sidebar nav hides the link entirely for client-role accounts, but the real defence is RLS — even if a curious developer types the URL directly, the SELECT from remarketing_flows returns zero rows because the policy filters them out.

This is the second time we've had to learn this lesson the hard way: client-side route gates are decoration. The only authentication boundary that matters is the one Postgres enforces. Anyone with the URL can type it in. Anyone with browser devtools can patch out a React conditional. The RLS policy can't be patched, can't be bypassed, can't be socially engineered.

What we deliberately did not build yet

Three things are conspicuously missing from V1, and they're missing on purpose. There's no runtime executor — saving a flow doesn't actually fire any emails. There's no CRM webhook receiver — the flow can't be triggered automatically by a real-world event. There's no contact list import — the audience node accepts a segment label but doesn't know who's in it.

Each of those is a project on its own. The executor needs a queue, a worker, retry logic, idempotency keys, deliverability monitoring. The webhook receiver needs to handle Pipedrive's, HubSpot's, and Attio's three slightly-different event payloads. The contact list import needs to handle deduplication, opt-out compliance, and email validation. We could have built smaller versions of all three and shipped a broken V1. Instead we shipped a tight V1 that gives the user a working canvas to plan their campaigns in, and we'll add the executor when there's a concrete pilot client willing to run it against real contacts.

This is the most useful debugging principle we know: never ship the part of the system that's hardest to debug remotely until there's a customer asking for it. A broken save button is a fifteen-minute fix. A broken email send to two hundred and forty contacts is a customer relationship.

The verdict

We spent about fifteen hours on the V1, including the Supabase schema, RLS policies, the React Flow canvas, the four node-kind inspectors, the save/load round-trip, and the role-gated route. The ActiveCampaign equivalent is around two hundred dollars a month at the contact volumes our pilot clinic actually has. The breakeven is at month two on agency build-rate maths, but the bigger win is that the canvas is part of the same codebase as their marketing site, their lead form, and their analytics dashboard. The marketing person stops being a human ETL pipeline between five SaaS tools. They just open the dashboard.

Email remarketing is an admin-only feature in our Velocity X template — gated to marketing and SLT roles via Supabase RLS. The next iteration will wire the runtime executor against Resend, which is already configured in our env vars for the discovery-call confirmation emails. Then a Pipedrive webhook to trigger flows from real CRM events. We're holding off on those until we have a pilot client running enough volume that we can debug the executor against real failures, not synthetic ones.