Building Mappd from Figma designs to private beta
Mappd came to me as a product idea and a set of Figma designs. I was responsible for turning those designs into a working private beta, including the interactive journey-mapping interface, API and data model, authentication, tenant isolation, testing strategy, AWS infrastructure and release process.
The product I was building
Mappd is a research and journey-mapping product for customer-experience teams. A journey is arranged into phases, stages and steps, with cards for research, jobs, insights, opportunities, initiatives, touchpoints and other evidence. The same material can also be explored in library and strategy views, linked back to the journey and filtered for different audiences.
The client had designed the main experience in Figma. My job was to work out the application behind it and take that through to private beta. I built the React interface, Hono API, PostgreSQL schema, WorkOS integration, deployment tooling and AWS environment. The designs established what each screen should look like, but there was still a lot of product behaviour to decide in code: how edits are saved, which roles can change what, how two people working on the same journey are warned about each other, and how the interface recovers when a request fails.
I completed the initial build in roughly two months. Working across the whole system meant I could make decisions without waiting for handovers, and I used several coding agents for pieces of work that could run independently. I still had to make sure those pieces agreed. An optimistic update in the interface had to match what the database eventually saved, and a permission shown in the interface also had to be enforced by the API and database.
Keeping the frontend and API in one contract
The product interface is a React single-page application. TanStack Router owns navigation, TanStack Query owns server-backed state and Hono provides the API. The frontend client is inferred from Hono's exported application type, so a route change updates the TypeScript contract used by the interface without maintaining a second OpenAPI model or generated SDK.
Keeping the frontend and API in one repository meant I could build a feature all the way through instead of treating it as a set of unrelated changes. For reusable research, for example, I added the database records and access rules, exposed them through the API, then connected the list, editing panel and journey map to the same data. The project is organised in the same way: research-specific code stays with the research feature, generic controls such as buttons and inputs remain shared, and the page brings the relevant features together. This makes the ownership of each piece clear when the feature changes later.
I enforce those boundaries with ESLint rather than expecting every contributor or agent to remember the folder rules. Shared code cannot import feature or page code, and feature modules cannot reach into the page layer. There is a short allowlist for modules that genuinely coordinate several features. A misplaced dependency therefore fails during development instead of becoming part of the architecture by accident.
Making complex canvas saves predictable
On the journey canvas, I keep immediate editing state in a Zustand store and leave server-backed data with TanStack Query. Dragging, inline editing and selection can respond straight away, but the canvas does not become a second permanent copy of the backend. Each mounted journey has one editor store. It starts with the cached server data and is brought back into line when a save response arrives.
I wrote a save gateway because the same request behaviour kept appearing in different controls. Independent changes can save immediately. Repeated text or layout changes use a debounced replace-latest lane. Structural operations, such as moving phases or steps, use a serial queue so their order is preserved. I call these behaviours save lanes. Each command chooses a lane and defines what the interface shows immediately, how to undo that change after a failure and which server data to reload afterwards.
The same gateway handles failed requests. If an optimistic save fails, it restores the cache state captured before the change and reports the error in one consistent way. A structural failure can stop later commands in that lane rather than letting them run against the wrong order. The API wraps multi-row structural changes in a transaction and sends the collaboration notification only after the transaction commits.
Permissions at the interface and API
WorkOS supplies identity, sessions and organisation membership. Mappd still has to translate that identity into product permissions. API middleware resolves the active vendor and project, checks whether the route needs read, write, membership-management or system access, then gives the handler a database object with that scope already attached.
The interface uses the same role rule to decide whether a control should be editable. A viewer sees saved text rather than an input that will fail when they press Save. Shared controls require an explicit `edit` or `static` mode. One part of a page can restrict editing further, but it cannot grant permission that the session did not have. If somebody loses access while a field is open, the draft is dropped and the saved value returns rather than a blur event quietly submitting it.
I back this up with checks at several levels. TypeScript makes the control mode mandatory. ESLint stops shared controls from reading session permissions directly, and a build-time check finds components that can write data without also checking the edit capability. The save gateway blocks a forbidden write in the interface, while the API still makes the final decision if somebody calls it directly.
Making row-level security maintainable
PostgreSQL row-level security is the final tenant boundary. Writing the policies was only part of the job. Every pooled connection needs the right tenant context, every relevant table needs the right policy, privileged operations need to be obvious in the code, and the tests must not run as a superuser that silently bypasses the rules.
I chose Drizzle over Prisma and other more abstract ORMs because it keeps typed application code close to SQL. That mattered for RLS. I could put the security setup behind `db.vendor()` and `db.project()`: each method opens a transaction, sets the current user, vendor, role and project with parameterised `set_config` calls, then passes a normal typed Drizzle transaction to the query. The rest of the API can use ordinary database code without repeating that setup.
Those context values apply only to the current transaction. PostgreSQL clears them when it ends, so a pooled connection cannot carry one customer's context into the next request. Normal product routes remain scoped even for a platform administrator. Cross-tenant work has to call the visibly privileged `db.superAdmin()` or `systemDb.system()` path.
I test the schema as a whole as well as testing individual queries. One database test fails if a public table has neither forced RLS nor a documented exemption. Another checks that the policy scope matches the tenant columns on the table. Seeded tests use the same non-superuser role as the application and prove that reads and writes cannot cross vendor or project boundaries. Composite foreign keys also prevent a valid ID from one project being attached to a record in another, including in maintenance code that is allowed to bypass RLS.
The AWS article linked below covers several easy mistakes: table owners can bypass policies, roles can be given `BYPASSRLS`, and tenant settings can leak through a connection pool if their lifetime is wrong. In Mappd those concerns live in the database wrapper, forced policies and runtime role, rather than being reimplemented in each API route.
What the realtime layer actually guarantees
I added a WebSocket layer so people can see who else has the same journey open and receive a warning after another collaborator changes it. The socket authenticates the session and checks journey access before joining the presence registry. Messages contain presence or invalidation information, not journey records, so HTTP responses and the query cache remain the source of truth.
These notifications are deliberately limited. Presence does not lock a field, and a successful-change notice does not resolve simultaneous edits. The save gateway can order requests from one browser, but it cannot coordinate two users. A stale save can still win, and the process-local WebSocket registry can miss somebody connected to another Fargate task. The interface says that the map has changed and offers a refresh; it does not claim to merge concurrent edits like Google Docs.
I documented those limits as part of the feature. If beta use shows repeated collisions on the same fields, the next step would be targeted active-editing signals or server-side version checks for those writes. A CRDT or a general multiplayer layer would add considerable complexity before there is evidence that the product needs it.
Log-backed end-to-end tests
A browser test can pass while the application emits a React warning, an API returns an unexpected error on a background request or the database performs the wrong write. I built a shared Playwright fixture that gives each test a deterministic log ID and carries it through browser events, API requests and optional database traces. At teardown it waits for the final client batch, reads the isolated log and archives a focused NDJSON snapshot outside Playwright's disposable results directory.
I treat the log as part of the assertion. Any undeclared warning or error fails the test, even if it occurred outside the request filter used for the readable snapshot. A test that deliberately exercises a 409 or another failure has to declare the expected tag, path, status or message and the exact count. An extra match fails, and an expected error that never occurred fails too. The fixture attaches the evidence before throwing, so the useful information is still there when teardown reports the failure.
Tests can also inspect the request log directly. Canvas tests assert the method, path and payload of structural writes, read-only tests prove that an interaction produced no mutation, and persistence tests reload or navigate before checking the server result. A custom ESLint rule rejects log capture that is never asserted and prevents arbitrary `waitForTimeout` calls except for narrowly documented settle cases. This keeps the suite based on observable application behaviour rather than pauses that happen to be long enough on one machine.
Knowing what the test suite actually covers
I keep a versioned YAML coverage registry alongside the tests. Each subject can point to unit, flow, API, database or infrastructure checks, with the last result, commit, duration and retained log path recorded by the reporter. The registry is shown in the development testing sidebar, which also exposes the same log filters and user-role switching used when writing the automated tests.
A passing test file is not automatically proof that a feature is covered; somebody still has to judge whether the assertions are meaningful. The registry makes gaps and stale evidence visible. It also catches cases where a component test has been moved or deleted but the coverage record still points to the old location.
The broader CI gate runs linting, type checks, the coverage check, duplication checks, dependency audit, builds and unit tests. A separate PostgreSQL job sets up the real schema and policies before running database and API tests. CDK assertions check details that are easy to get wrong in review, including private storage, production deletion protection, secret sources, CloudFront behaviours and which security group can reach RDS.
Private media rather than browser credentials
Journey cards and profiles can include images, but the browser never receives AWS credentials or a direct upload URL. Multipart uploads go through the Hono API. The service enforces the size while streaming, checks both the declared MIME type and magic bytes, decodes and auto-orients the image, rejects unreasonable dimensions, then resizes within a 2400-pixel boundary before writing to a vendor-prefixed key in a private S3 bucket.
The application stores a stable proxy path rather than an expiring S3 address. A read checks the active vendor and object prefix before returning a short-lived signed redirect. Deleting a project image first removes its active card references and canvas placements, then deletes the object, so an S3 failure can leave an unused file but not a visible card pointing at nothing. Avatars use a separate user-owned path and an authoritative 256-pixel crop on the server.
Building and operating the AWS environment
I defined staging and production in AWS CDK using the same stack classes and reviewed environment-specific configuration. CloudFront serves the dashboard and landing site from private S3 origins and proxies API, authentication and WebSocket traffic to an Application Load Balancer. The Hono service runs on ECS Fargate in `eu-west-2`, PostgreSQL runs on RDS and runtime secrets come from Secrets Manager. Non-secret environment discovery lives in Parameter Store.
Production has two API tasks, database deletion protection, retained resources, longer backups and CloudFormation termination protection. A CDK aspect rejects plaintext sources for protected runtime secrets. Logs from CloudFront, the load balancer, the API and AWS management events have defined destinations and retention, and CloudWatch alarms publish to an environment-specific topic.
I wrapped the stacks in preflight, deployment and smoke-test commands rather than relying on remembered CDK arguments. The scripts select the environment and AWS profile, show what will be published, prevent overlapping releases, upload the static applications, deploy the API and record the resulting health and version checks. I built that release path before the beta so deploying the product did not depend on a set of steps kept in my head.
What I would change with more usage data
The current beta has clear limits. Collaboration warns about stale work but does not merge it. Production runs two API tasks, but each Fargate task holds its own in-memory WebSocket registry, so presence is not shared between them. Journey-level membership exists in the data model, while the current HTTP boundary is the vendor role. Each of those is documented so a later decision starts from the real system rather than an assumption.
I stopped at those boundaries deliberately. The security, save behaviour, tests and infrastructure are in place for real beta use, but I did not add distributed coordination before the product had evidence that it needed it. The beta should show where people actually collide, which research structures they reuse and whether any part of a journey needs a finer permission boundary. I would use that evidence to decide what to build next.