Rui Silva

Case study

MiroPay: Miro's self-serve checkout and billing

Overview

MiroPay is Miro's self-serve checkout and billing: how customers pay for plans, seats, add-ons, and AI products, change a payment method, add or remove licenses, and upgrade or downgrade a subscription, for individuals and teams (through Business).

It is the pioneer microservice Miro deployed at scale, and I work on it in Miro's Growth Monetization team.

I proposed extracting checkout from the client monolith into a dedicated service. The team aligned on the direction, and I was responsible for the migration, from the checkout customers see to the backend layer behind it.

I'm also a first-responder on checkout incidents, and I reworked how the app builds, tests, and ships.

  • CI pipeline time reduced from approximately 25 minutes to under 4 minutes
  • 96% less build time (combined changes)
  • ~50,000 lines extracted from the client monolith
  • 80%+ of the company uses MiTest daily

Architecture

Frontend
A Next.js app that renders checkout in a cross-origin iframe, using server-side rendering to move some static rendering and heavy logic off the client.
Backend-for-Frontend (BFF)
A Node.js layer in the same service that orchestrates checkout: validation, subscription and entitlement context, country-specific tax context, and order flows, so the client doesn't call several backend services itself.
Internal services
The BFF talks to internal billing and entitlement services over native gRPC, with TypeScript contracts generated from Protocol Buffers.
Payments
Stripe Elements (the Payment Element) renders Stripe-hosted card fields inside checkout. The publishable key only identifies Miro's Stripe account, so it is safe in the browser; the client secret, returned by the BFF's init flow for one specific payment or setup, lets the browser confirm it directly with Stripe. Raw card details go to Stripe and stay outside Miro's application backend, which reduces the associated PCI DSS scope.
Subscriptions
Stigg handles the catalog, entitlements, and plan, seat, and add-on changes.
Tax and currency
Stripe calculates tax. The frontend and BFF stay agnostic to country-specific tax/VAT rule combinations, and checkout and order flows support multiple currencies.
Asynchronous outcomes
3D Secure, webhooks, and delayed confirmations are handled as events: the UI listens across the iframe boundary and the BFF processes webhooks, rather than relying on a single request/response.
Payment providers
We prepared the system with a payment-provider abstraction layer designed to accommodate multiple PSP integrations, keeping provider-specific concerns separate from the core checkout flow.

Decisions and trade-offs

A. Extracting checkout into MiroPay

Context
Self-serve checkout and billing lived in Miro's client monolith. I proposed extracting them into a dedicated service; the team aligned on the direction, and I was responsible for the migration.
Decision
Move checkout into MiroPay, a dedicated Next.js microservice with its own BFF. The migration extracted ~50,000 lines of checkout-related code from the client monolith.
Alternative considered
Keep checkout in the client monolith and keep scaling the monolith backend's REST endpoints.
Why
Checkout deploys no longer depend on the main application's release cycle, and checkout gets a clear domain boundary. Within that boundary, we prepared a payment-provider abstraction layer designed to accommodate multiple PSP integrations.
What it cost
Another service to own and operate, cross-origin iframe communication, and migrating live paying traffic safely.
How the cost was handled
Traffic moved progressively behind feature flags, using SplitIO and the in-house rollout service, with rollback available.

B. Native gRPC from the BFF

Context
Miro's internal backend services already used gRPC as their standard protocol, so the BFF needed to reach internal billing and entitlement services through it.
Decision
I integrated the Node.js BFF directly with native gRPC clients, using TypeScript contracts generated from Protocol Buffers.
Alternative considered
An intermediary REST proxy in front of the internal services.
Why
Typed contracts, alignment with the internal standard, and no unnecessary proxy layer between the BFF and the services.
What it cost
A more complex local build setup, and Protobuf code generation inside a TypeScript/Node.js project.
How the cost was handled
Protobuf compilation is automated in the build pipelines.

C. Stripe Elements and payment-data isolation

Context
Checkout needed to collect card details securely without sending customers away from Miro.
Decision
Checkout collects card details through Stripe Elements (the Payment Element) in Stripe-hosted fields. I implemented the Stripe Elements frontend integration and coordinated with the team on the supporting payment setup, including SetupIntent creation.
Alternative considered
Custom card fields hosted on Miro's own domain, or a redirect to a separate hosted checkout.
Why
Sensitive card details stay outside Miro's application backend while checkout stays integrated, which reduces the associated PCI DSS scope.
What it cost
Cross-origin messaging and UI state synchronisation, limited customisation inside Stripe-hosted fields, and asynchronous outcomes: 3D Secure redirects, webhooks, and delayed confirmations.
How the cost was handled
Client-side event handling across the iframe boundary, and webhook processing in the BFF for delayed payment confirmations.

D. BFF orchestration and checkout-context initialisation

Context
Opening checkout needs subscription state, entitlement checks, validation, and country-specific tax context. I owned and implemented most of the BFF orchestration, coordinating and delegating parts of the work across the team.
Decision
Centralise checkout orchestration, validation, and subscription and entitlement context in the BFF. Stripe still calculates tax; the BFF coordinates the context around it.
Alternative considered
The React client orchestrating multiple sequential REST/GraphQL calls itself.
Why
No client-side request waterfalls across backend services, less complexity in the client bundle, sensitive billing and discount logic kept on the server, and a single orchestration layer.
What it cost
Additional checkout-context initialisation latency in complex multi-tenant account scenarios.
How the cost was handled
Independent internal gRPC calls run concurrently with Promise.all, and the immutable pricing catalog and static entitlement data are cached in memory.

E. Developer tooling as operational leverage

Context
Setting up a test billing account was slow and needed manual work, and the internal translation CLI downloaded more history than it needed.
Decision
I built and maintain MiTest, a Chrome extension with a DevTools panel, and I re-engineered the translation CLI with request filtering and response caching.
Alternative considered
Keep relying on manual database scripts and APIs for test setup, and accept the CLI's existing behaviour.
Why
Faster feedback loops, and QA, product managers, and support can create complex billing test states on their own.
What it cost
Ongoing ownership of internal tools alongside product-feature delivery.
How the cost was handled
MiTest's mock generation is integrated into automated E2E tests, so it is part of the team's test infrastructure.

Reliability and operations

  • I'm a first-responder on checkout incidents and work on payment reliability, observability, and operational health across Growth Monetization.
  • I identified and fixed a trial-license validation gap across backend layers, and built internal reporting so fraud and support teams could identify abnormal trials and assess financial exposure.
  • The migration moved live paying traffic progressively behind feature flags, with rollback available.
  • Asynchronous payment outcomes are handled as events, so the UI and BFF react to 3D Secure, webhooks, and delayed confirmations rather than a single request/response.

Outcomes

  • CI pipeline: ~25 minutes to under 4 minutes.
  • Build time: cut by 96% across the combined optimisation work.
  • Local startup: 32% faster.
  • ~50,000 lines of checkout-related code extracted from the client monolith into MiroPay.
  • MiTest: more than 80% of the company uses it daily, and test billing-account setup went from ~3 minutes to 5 seconds.
  • Translation CLI: ~23s to ~2.1s.
  • Migrated the payments app from Next.js 14 to 16 and standardised it on the App Router.
  • Shipped checkout flows for add-ons, AI credit products, Business AI, and free-to-paid experiments.

The CI, build, and startup gains came from combined work across the application lifecycle, not one change:

Dependencies
Simplified, then removed, the custom Express server that started Next.js, and swapped heavy dependencies for lighter ones (Zod → Valibot).
Compilation
Moved Next.js compilation from Webpack to Turbopack, and linting and formatting from ESLint/Prettier to Oxc.
Testing
Moved lint, format, unit, and E2E checks out of the Docker build onto right-sized GitHub-hosted runners.
CI orchestration
Moved build work onto GitHub-hosted runners and maximised parallel execution across CI stages.
Containerisation
Lightweight Docker base images without Node.js, carrying only the build artifacts and the Bun runtime, with fewer layers and operations.
Deployment
Next.js standalone output and a deployment adapter that produces a Bun executable, with Bun also used for scripts and tooling.