Thinking Out Loud

Soren Lab's Monorepo: Payments, Email, APIs, and UI

Anthony Shead/July 1, 2026/4 min read
Soren Lab's Monorepo: Payments, Email, APIs, and UI
fig. 1 - Soren Lab's Monorepo: Payments, Email, APIs, and UI

This is what sits on top: five packages that deliver actual product functionality.

@soren/stripe: The wrapper that owns all payment logic No app imports from the Stripe SDK directly. Every payment operation goes through this package, so if anything about the integration changes, there's one place to fix it. Two things I learned building this that weren't in any getting-started guide: Webhook signature verification requires the raw request body, not parsed JSON. Stripe computes the signature over the raw bytes. If you parse the JSON first and reconstruct it, whitespace differences break the check. In Next.js, that means using req.text() before passing anything to the verification function. Subscription cancellation should almost always use cancel_at_period_end: true rather than immediate cancellation. This keeps the user active through the billing period they already paid for, then cancels. Cutting off access the moment someone cancels is usually the wrong user experience.

@soren/notifications Email with React components All outbound email calls sendEmail() from this package. What you pass is a React component, a template, and Resend renders it to HTML and handles delivery. Why React Email over writing HTML email strings: email clients are inconsistent enough that hand-coding HTML emails that work everywhere requires writing table-based layouts and testing across every major client. React Email provides components that abstract all of that; you write JSX, it produces the HTML that actually renders correctly in Gmail, Outlook, and mobile clients. Two templates are live: a welcome email for new client onboarding and a project status update email. A Discord notification integration is stubbed but not built; the decision on scope, event triggers, and bot vs. server setup is still open.

@soren/apis The contract between apps and API routes This one's the most interesting architecturally. It's the shared contract layer: Zod schemas for validating what comes in from API requests, TypeScript interfaces for the shapes that go out in responses. Zero runtime dependencies on the database, auth, or payment packages. Why zero dependencies? A form component might import a Zod schema to validate input before submitting. If this package imported the database layer, Mongoose would end up in a browser bundle. Keeping it dependency-free means it can be imported anywhere, server or client, safely. The clearest thing I worked out building this: the Zod schemas here and the Mongoose schemas in the database package are different things doing different jobs. Mongoose schemas define what the database stores and understands: ObjectIds, indexes, document structure. Zod schemas define what the API accepts over HTTP plain JSON from a request body. A field stored in MongoDB as an ObjectId arrives from the client as a plain string. The Zod schema validates the string. The database layer converts it to an ObjectId. They're allowed to differ because they're doing different things. Date fields are handled similarly: API response interfaces use ISO strings, not JavaScript Date objects. Mongoose documents have Date instances that Next.js can't serialize to React props directly. Every API route explicitly converts before returning.

@soren/ui One component library for all apps Every app in the monorepo uses the same components from here: the same button, dialog, table, avatar. Built on Radix UI for accessibility behavior and Tailwind for styling. cn() is the utility everything depends on. It combines two libraries: clsx for conditional class logic (booleans, arrays, and objects into a single string) and tailwind-merge for Tailwind-specific deduplication (if you have px-2 and px-4 both in the string, tailwind-merge knows only one can win and keeps the last one). You need both; clsx handles the logic, tailwind-merge handles the Tailwind semantics. The button and badge components use CVA (class-variance-authority) to define variants as typed schemas. You declare the variant dimensions and their class values; CVA generates the function, and TypeScript infers the prop types from the schema. Passing a variant that doesn't exist is a compile error, which beats the alternative of nested ternaries that TypeScript can't check. The data table is built on TanStack Table. Column definitions declare what data each column reads and how to display it; the component handles sorting, filtering, and pagination. Fully generic, works with any data shape any of the apps need.

@soren/calendar Coming soon The package structure is in place, but the files are empty. Google Calendar integration is coming; the right time to build it is after the scope and auth approach are locked down. For now, the env schema has the credential fields waiting.

New posts, in your inbox

Roughly one a week. Confirm once, unsubscribe anytime.

Writing like this is reader-supported.Support on Patreon →

0 comments

Read next