<?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: Vlad Zoff</title>
    <description>The latest articles on DEV Community by Vlad Zoff (@vladzoff).</description>
    <link>https://dev.to/vladzoff</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3992934%2F61af1d3c-0ba9-465b-9e29-befc3796b413.jpeg</url>
      <title>DEV Community: Vlad Zoff</title>
      <link>https://dev.to/vladzoff</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vladzoff"/>
    <language>en</language>
    <item>
      <title>SaaS Billing Models: Subscriptions, Seats, Credits, and Add-ons</title>
      <dc:creator>Vlad Zoff</dc:creator>
      <pubDate>Tue, 07 Jul 2026 12:12:24 +0000</pubDate>
      <link>https://dev.to/vladzoff/saas-billing-models-subscriptions-seats-credits-and-add-ons-39db</link>
      <guid>https://dev.to/vladzoff/saas-billing-models-subscriptions-seats-credits-and-add-ons-39db</guid>
      <description>&lt;p&gt;Billing is product strategy expressed in code. If your starter only handles a single checkout link, you will rebuild it as soon as pricing changes.&lt;/p&gt;

&lt;p&gt;This article breaks down the billing models a SaaS foundation should support.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flat Subscription
&lt;/h2&gt;

&lt;p&gt;The simplest model is one plan, one recurring price.&lt;/p&gt;

&lt;p&gt;Use it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The product has a clear single package.&lt;/li&gt;
&lt;li&gt;Usage does not vary much across customers.&lt;/li&gt;
&lt;li&gt;Support and infrastructure costs are predictable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Implementation needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Checkout session.&lt;/li&gt;
&lt;li&gt;Subscription created webhook.&lt;/li&gt;
&lt;li&gt;Subscription updated webhook.&lt;/li&gt;
&lt;li&gt;Subscription canceled webhook.&lt;/li&gt;
&lt;li&gt;Customer portal.&lt;/li&gt;
&lt;li&gt;Plan state in the database.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tiered Plans
&lt;/h2&gt;

&lt;p&gt;Most SaaS products need at least two paid tiers.&lt;/p&gt;

&lt;p&gt;Typical differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Feature access.&lt;/li&gt;
&lt;li&gt;Usage limits.&lt;/li&gt;
&lt;li&gt;Support level.&lt;/li&gt;
&lt;li&gt;Number of seats.&lt;/li&gt;
&lt;li&gt;Data retention.&lt;/li&gt;
&lt;li&gt;Integrations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store &lt;code&gt;planId&lt;/code&gt; in your subscription table and map it to a local plan catalog. Do not rely on provider price IDs as your product logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seat-Based Billing
&lt;/h2&gt;

&lt;p&gt;Seat billing is common for B2B SaaS. It aligns price with customer team size.&lt;/p&gt;

&lt;p&gt;You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Current seat count.&lt;/li&gt;
&lt;li&gt;Optional max seat cap.&lt;/li&gt;
&lt;li&gt;Member invite logic.&lt;/li&gt;
&lt;li&gt;Checkout or subscription update flow.&lt;/li&gt;
&lt;li&gt;Billing UI that explains renewal impact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzt8d7l8b2micbaki8n4g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzt8d7l8b2micbaki8n4g.png" alt="Billing screen" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Usage Credits
&lt;/h2&gt;

&lt;p&gt;Usage credits work well for AI, API calls, file processing, or metered workflows.&lt;/p&gt;

&lt;p&gt;Track:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total credits.&lt;/li&gt;
&lt;li&gt;Used credits.&lt;/li&gt;
&lt;li&gt;Reset date.&lt;/li&gt;
&lt;li&gt;Usage events.&lt;/li&gt;
&lt;li&gt;Budget alerts.&lt;/li&gt;
&lt;li&gt;Overage policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Credits should be visible to the customer before they become a billing surprise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add-ons
&lt;/h2&gt;

&lt;p&gt;Add-ons let you expand revenue without forcing every customer into a higher tier.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extra storage.&lt;/li&gt;
&lt;li&gt;White-labeling.&lt;/li&gt;
&lt;li&gt;Priority support.&lt;/li&gt;
&lt;li&gt;Advanced analytics.&lt;/li&gt;
&lt;li&gt;Additional AI credits.&lt;/li&gt;
&lt;li&gt;Premium integrations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your checkout and subscription state need to support multiple line items, not just one plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trials
&lt;/h2&gt;

&lt;p&gt;Trials are useful when activation requires product usage. They are less useful when setup is complex and a sales conversation is needed.&lt;/p&gt;

&lt;p&gt;If you support trials, store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Trial end date.&lt;/li&gt;
&lt;li&gt;Trial status.&lt;/li&gt;
&lt;li&gt;Conversion events.&lt;/li&gt;
&lt;li&gt;Reminder schedule.&lt;/li&gt;
&lt;li&gt;Cancellation behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One-Time Purchases
&lt;/h2&gt;

&lt;p&gt;Some developer products sell lifetime access or source code licenses. That model needs different state than recurring SaaS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;License tier.&lt;/li&gt;
&lt;li&gt;Project count.&lt;/li&gt;
&lt;li&gt;Update period.&lt;/li&gt;
&lt;li&gt;Support period.&lt;/li&gt;
&lt;li&gt;Delivery workflow.&lt;/li&gt;
&lt;li&gt;Receipt and access email.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not mix one-time license logic into recurring subscription code without clear types.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks Are the Source of Truth
&lt;/h2&gt;

&lt;p&gt;Checkout success pages are not enough. Provider webhooks should update your database.&lt;/p&gt;

&lt;p&gt;At minimum, handle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Checkout completed.&lt;/li&gt;
&lt;li&gt;Subscription created.&lt;/li&gt;
&lt;li&gt;Subscription updated.&lt;/li&gt;
&lt;li&gt;Subscription deleted.&lt;/li&gt;
&lt;li&gt;Payment failed.&lt;/li&gt;
&lt;li&gt;Refund.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Log webhook delivery and errors. Silent billing failures are expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Admin Billing Support
&lt;/h2&gt;

&lt;p&gt;Your admin panel should show:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;User name and email.&lt;/li&gt;
&lt;li&gt;Provider.&lt;/li&gt;
&lt;li&gt;Plan ID.&lt;/li&gt;
&lt;li&gt;Status.&lt;/li&gt;
&lt;li&gt;Seat count.&lt;/li&gt;
&lt;li&gt;Renewal date.&lt;/li&gt;
&lt;li&gt;Cancellation state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Support should not need direct database access to answer a billing question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the Model You Can Explain
&lt;/h2&gt;

&lt;p&gt;The best billing model is not the most sophisticated one. It is the one customers understand and your system can enforce.&lt;/p&gt;

&lt;p&gt;Start with the simplest model that matches value, but choose a codebase that can add seats, credits, add-ons, and trials when your pricing evolves.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://codapult.dev" rel="noopener noreferrer"&gt;Codapult&lt;/a&gt; ships billing adapters for Stripe, LemonSqueezy, and Polar with normalized webhook handling, subscription state in the database, seat tracking, usage credits, and a customer portal — switchable via &lt;code&gt;PAYMENT_PROVIDER&lt;/code&gt; without touching application code. The &lt;a href="https://codapult.dev/docs/payments/overview" rel="noopener noreferrer"&gt;payments docs&lt;/a&gt; show the integration points.&lt;/p&gt;

</description>
      <category>billing</category>
      <category>stripe</category>
      <category>lemonsqueezy</category>
      <category>saas</category>
    </item>
    <item>
      <title>Auth, Teams, and RBAC in a Next.js SaaS App</title>
      <dc:creator>Vlad Zoff</dc:creator>
      <pubDate>Tue, 07 Jul 2026 12:07:58 +0000</pubDate>
      <link>https://dev.to/vladzoff/auth-teams-and-rbac-in-a-nextjs-saas-app-2mla</link>
      <guid>https://dev.to/vladzoff/auth-teams-and-rbac-in-a-nextjs-saas-app-2mla</guid>
      <description>&lt;p&gt;Authentication gets users into the app. Authorization decides what they can do once they are there. In SaaS products, the second part is where many starters are too thin.&lt;/p&gt;

&lt;p&gt;This guide covers the practical model: platform roles, organization roles, protected routes, admin access, and future enterprise needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the User Record
&lt;/h2&gt;

&lt;p&gt;Keep the user record to identity only — id, name, email, verification state, avatar, platform role, 2FA state, timestamps. Nothing organization-specific belongs here, because the same person can be an owner in one workspace and a viewer in another. The moment you store a permission field directly on the user, you've hard-coded an assumption that breaks the first time someone joins a second organization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add Platform Roles
&lt;/h2&gt;

&lt;p&gt;Platform roles are global. Most apps only need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;user&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;admin&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The admin role controls platform screens such as &lt;code&gt;/admin/users&lt;/code&gt;, &lt;code&gt;/admin/subscriptions&lt;/code&gt;, and &lt;code&gt;/admin/feature-flags&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Do not use platform admin for customer workspace permissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add Organization Memberships
&lt;/h2&gt;

&lt;p&gt;Organization roles should answer what a user can do inside one workspace:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;owner&lt;/code&gt;: billing, members, settings, deletion.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;admin&lt;/code&gt;: most management actions except ownership transfer.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;member&lt;/code&gt;: normal product usage.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;viewer&lt;/code&gt;: read-only access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This gives you enough granularity without creating a complex permission system too early.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxelz780kqmmayku9eg1a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxelz780kqmmayku9eg1a.png" alt="Organization Memberships" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Protect Routes at Multiple Layers
&lt;/h2&gt;

&lt;p&gt;Route protection should happen in more than one place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Middleware or proxy redirects unauthenticated users.&lt;/li&gt;
&lt;li&gt;Layouts block protected app areas.&lt;/li&gt;
&lt;li&gt;Server actions verify the session.&lt;/li&gt;
&lt;li&gt;API routes verify the session.&lt;/li&gt;
&lt;li&gt;Admin operations verify platform role.&lt;/li&gt;
&lt;li&gt;Organization operations verify membership and org role.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The UI can hide buttons, but the server must enforce permissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep Auth Pages Out of Search
&lt;/h2&gt;

&lt;p&gt;Sign-in, sign-up, dashboard, and admin pages should not be indexed. They are not acquisition pages, and search engines do not need them.&lt;/p&gt;

&lt;p&gt;Use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;robots&lt;/code&gt; metadata with &lt;code&gt;index: false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;robots.txt&lt;/code&gt; disallow rules for protected paths.&lt;/li&gt;
&lt;li&gt;Sitemap exclusion for auth-only pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps SEO focus on marketing, docs, blog, pricing, and comparison pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plan for Enterprise Auth Without Building It Too Early
&lt;/h2&gt;

&lt;p&gt;Enterprise customers often ask for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SAML SSO.&lt;/li&gt;
&lt;li&gt;SCIM provisioning.&lt;/li&gt;
&lt;li&gt;Enforced 2FA.&lt;/li&gt;
&lt;li&gt;Audit logs.&lt;/li&gt;
&lt;li&gt;Domain verification.&lt;/li&gt;
&lt;li&gt;Custom session policies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You do not need to ship every enterprise feature on day one. You do need a data model that can add them without rewriting auth and teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Support Impersonation Carefully
&lt;/h2&gt;

&lt;p&gt;Impersonation is powerful for support, but it is sensitive.&lt;/p&gt;

&lt;p&gt;Rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only platform admins can impersonate.&lt;/li&gt;
&lt;li&gt;Show a visible impersonation banner.&lt;/li&gt;
&lt;li&gt;Log the impersonation start and end.&lt;/li&gt;
&lt;li&gt;Never let impersonation bypass destructive confirmation steps silently.&lt;/li&gt;
&lt;li&gt;Avoid using impersonation for billing provider actions when possible.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test the Permission Matrix
&lt;/h2&gt;

&lt;p&gt;Write tests for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anonymous user access.&lt;/li&gt;
&lt;li&gt;Authenticated user access.&lt;/li&gt;
&lt;li&gt;Admin-only routes.&lt;/li&gt;
&lt;li&gt;Owner-only organization settings.&lt;/li&gt;
&lt;li&gt;Member actions.&lt;/li&gt;
&lt;li&gt;Viewer restrictions.&lt;/li&gt;
&lt;li&gt;Locale-prefixed protected routes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Auth regressions are high-risk because they often expose data, not just break UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Practical Baseline
&lt;/h2&gt;

&lt;p&gt;None of this is exotic. A real auth provider, protected routes at every layer, a platform admin role, an organization membership table with roles, server-side permission checks, audit logging, and noindex on anything that isn't marketing. That's the baseline that survives contact with a real customer — not an enterprise IAM project, just enough structure that adding SSO later doesn't mean rewriting how permissions work.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://codapult.dev" rel="noopener noreferrer"&gt;Codapult&lt;/a&gt; includes this full auth and teams layer out of the box — email/password, OAuth, magic links, TOTP 2FA, SAML SSO, organizations, RBAC, invitations, protected routes and audit logs — switchable between Better-Auth and Kinde via one environment variable. Explore the implementation map in the &lt;a href="https://codapult.dev/docs/authentication/overview" rel="noopener noreferrer"&gt;authentication docs&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>auth</category>
      <category>teams</category>
      <category>rbac</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Designing Removable Modules in a Next.js SaaS Starter</title>
      <dc:creator>Vlad Zoff</dc:creator>
      <pubDate>Tue, 07 Jul 2026 12:03:15 +0000</pubDate>
      <link>https://dev.to/vladzoff/designing-removable-modules-in-a-nextjs-saas-starter-1n87</link>
      <guid>https://dev.to/vladzoff/designing-removable-modules-in-a-nextjs-saas-starter-1n87</guid>
      <description>&lt;p&gt;Most SaaS starters say they are modular.&lt;/p&gt;

&lt;p&gt;The useful question is: what happens when you remove a module?&lt;/p&gt;

&lt;p&gt;If disabling a feature leaves dead navigation links, broken sitemap entries, missing imports, route handlers that still accept requests, or database tables nobody understands, the module was not really modular. It was just grouped in a folder.&lt;/p&gt;

&lt;p&gt;Removable modules need boundaries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6rlgevh5sdpkejv5tae6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6rlgevh5sdpkejv5tae6.png" alt="Module architecture docs" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Start With User-Facing Surfaces
&lt;/h2&gt;

&lt;p&gt;A module usually shows up in more places than its main page suggests. Take a waitlist feature: there's the public signup route, sure, but also an admin view, an API route, a database table, an email template, a navbar link, a footer link, a sitemap entry. Delete the public page and the feature is still there — just invisible until someone hits the API route directly or wonders why waitlist_entry is sitting in the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature Flags Are Not Enough
&lt;/h2&gt;

&lt;p&gt;Feature flags hide behavior at runtime.&lt;/p&gt;

&lt;p&gt;They do not remove code.&lt;/p&gt;

&lt;p&gt;That distinction matters. Runtime flags are useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hiding navigation.&lt;/li&gt;
&lt;li&gt;Returning 404 for disabled routes.&lt;/li&gt;
&lt;li&gt;Removing sitemap entries.&lt;/li&gt;
&lt;li&gt;Preventing API access.&lt;/li&gt;
&lt;li&gt;Running one codebase in multiple modes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But if a buyer wants a smaller codebase, the setup tool also needs to delete files and update references.&lt;/p&gt;

&lt;p&gt;A good starter supports both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Feature flags for runtime control.&lt;/li&gt;
&lt;li&gt;Pruning for permanent removal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Route Groups Help
&lt;/h2&gt;

&lt;p&gt;Next.js App Router route groups are useful for keeping surfaces understandable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/app/[locale]/
  (marketing)/
  (auth)/
  (dashboard)/
  admin/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That structure makes it clear which pages are public, protected, authentication-only, or platform admin.&lt;/p&gt;

&lt;p&gt;It also lets you reason about SEO. Marketing pages are indexable. Auth, dashboard, and admin pages should be noindex and blocked where appropriate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep Shared Code Honest
&lt;/h2&gt;

&lt;p&gt;The hardest modules to remove are the ones that leak into shared files.&lt;/p&gt;

&lt;p&gt;Common leak points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;navigation.ts&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sitemap.ts&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;robots.ts&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;proxy.ts&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Shared schema files.&lt;/li&gt;
&lt;li&gt;Shared validation schemas.&lt;/li&gt;
&lt;li&gt;Shared server actions.&lt;/li&gt;
&lt;li&gt;Shared dashboard layouts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a module needs entries in those files, make the entries obvious and grouped. If a setup wizard removes the module, it should know exactly what to prune.&lt;/p&gt;

&lt;h2&gt;
  
  
  API Routes Need Gates Too
&lt;/h2&gt;

&lt;p&gt;Disabled UI is not security.&lt;/p&gt;

&lt;p&gt;If a module has API routes, those routes need their own gates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auth check.&lt;/li&gt;
&lt;li&gt;Permission check.&lt;/li&gt;
&lt;li&gt;Rate limit.&lt;/li&gt;
&lt;li&gt;Feature flag check.&lt;/li&gt;
&lt;li&gt;Input validation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The proxy can return 404 for disabled feature routes, but route handlers should still follow the same protected pattern. That keeps modules safe when moved, copied, or extended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database Tables Should Be Named by Domain
&lt;/h2&gt;

&lt;p&gt;Module data should be easy to identify.&lt;/p&gt;

&lt;p&gt;Good table names:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;waitlist_entry&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;feature_request&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;audit_log&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;outgoing_webhook&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;workflow&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ambiguous table names make removal riskier. If the table name tells you the module, migration work is easier to review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests Should Prove Removal Boundaries
&lt;/h2&gt;

&lt;p&gt;Useful tests include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Disabled feature returns 404.&lt;/li&gt;
&lt;li&gt;Disabled feature is absent from sitemap.&lt;/li&gt;
&lt;li&gt;Disabled feature is absent from navigation.&lt;/li&gt;
&lt;li&gt;API route rejects unauthenticated access.&lt;/li&gt;
&lt;li&gt;Module-specific queries still work when enabled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You do not need massive coverage for every module. You need enough tests to prevent obvious leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plugins Are for Bigger Boundaries
&lt;/h2&gt;

&lt;p&gt;Not every removable feature should be a plugin.&lt;/p&gt;

&lt;p&gt;A simple waitlist page can be a module.&lt;/p&gt;

&lt;p&gt;A CRM with pages, routes, hooks, schema, components, AI helpers, and install commands is a better plugin candidate.&lt;/p&gt;

&lt;p&gt;Use plugins when the feature has its own lifecycle and customer value. Use modules when the feature is part of the core application shape.&lt;/p&gt;

&lt;p&gt;None of this needs to be exhaustive. It needs to be enough that "modular" describes the codebase instead of describing the marketing copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Payoff
&lt;/h2&gt;

&lt;p&gt;Removable modules give buyers confidence.&lt;/p&gt;

&lt;p&gt;They can start broad, then strip what they do not need. Or they can keep a larger operational surface without building it themselves.&lt;/p&gt;

&lt;p&gt;The codebase stays understandable because module boundaries are explicit.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://codapult.dev" rel="noopener noreferrer"&gt;Codapult&lt;/a&gt; documents removable features in the &lt;a href="https://codapult.dev/docs/modules/overview" rel="noopener noreferrer"&gt;modules overview&lt;/a&gt;, including routes, dependencies, and manual removal notes.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>architecture</category>
      <category>typescript</category>
      <category>saas</category>
    </item>
    <item>
      <title>Turso + Drizzle vs PostgreSQL + Prisma for a SaaS Boilerplate</title>
      <dc:creator>Vlad Zoff</dc:creator>
      <pubDate>Tue, 07 Jul 2026 12:01:23 +0000</pubDate>
      <link>https://dev.to/vladzoff/turso-drizzle-vs-postgresql-prisma-for-a-saas-boilerplate-4pmb</link>
      <guid>https://dev.to/vladzoff/turso-drizzle-vs-postgresql-prisma-for-a-saas-boilerplate-4pmb</guid>
      <description>&lt;p&gt;Database choices in SaaS starters are more emotional than they should be.&lt;/p&gt;

&lt;p&gt;Turso, PostgreSQL, Drizzle, Prisma, Supabase, Neon, and SQLite all have real strengths. The right choice depends on what the starter optimizes for: local setup, operational familiarity, edge reads, schema control, ecosystem, or enterprise expectations.&lt;/p&gt;

&lt;p&gt;This is not a purity argument. It is a trade-off map.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turso + Drizzle
&lt;/h2&gt;

&lt;p&gt;Turso is hosted libSQL, a SQLite-compatible database with cloud features. Drizzle is a lightweight TypeScript ORM with explicit schema definitions and SQL-like query builders.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Simple local development with &lt;code&gt;file:local.db&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fast setup for new projects.&lt;/li&gt;
&lt;li&gt;Lightweight query layer.&lt;/li&gt;
&lt;li&gt;TypeScript-first schema and query types.&lt;/li&gt;
&lt;li&gt;Good fit for many SaaS workloads that do not need heavyweight relational operations on day one.&lt;/li&gt;
&lt;li&gt;Multi-region read replica story through Turso.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Some teams are less familiar with SQLite/libSQL in production.&lt;/li&gt;
&lt;li&gt;PostgreSQL has a larger ecosystem.&lt;/li&gt;
&lt;li&gt;Enterprise buyers may ask for Postgres by default.&lt;/li&gt;
&lt;li&gt;Some third-party tools assume Postgres.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Turso + Drizzle is a strong default when you value small setup and explicit TypeScript code.&lt;/p&gt;

&lt;h2&gt;
  
  
  PostgreSQL + Prisma
&lt;/h2&gt;

&lt;p&gt;PostgreSQL is the familiar production default for many SaaS teams. Prisma is widely adopted and has a comfortable schema/client workflow.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Very familiar to engineering teams.&lt;/li&gt;
&lt;li&gt;Huge hosting ecosystem: Neon, Supabase, RDS, Railway, Render, Fly, and more.&lt;/li&gt;
&lt;li&gt;Strong relational feature set.&lt;/li&gt;
&lt;li&gt;Many analytics, BI, and integration tools support it directly.&lt;/li&gt;
&lt;li&gt;Prisma has excellent discoverability and a large community.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Prisma can add more abstraction than some TypeScript teams want.&lt;/li&gt;
&lt;li&gt;Generated clients and migrations introduce their own workflow.&lt;/li&gt;
&lt;li&gt;Local setup can be heavier than an SQLite file.&lt;/li&gt;
&lt;li&gt;Switching away from Prisma later is rarely free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PostgreSQL + Prisma is a strong default when team familiarity and ecosystem matter more than minimal setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  PostgreSQL + Drizzle
&lt;/h2&gt;

&lt;p&gt;This middle path is increasingly attractive precisely because it refuses to pick a side: you get PostgreSQL's familiar hosting story and Drizzle's thinner query layer. The trade-off is that you still need a real Postgres instance for local dev — no single-file convenience — and Drizzle's ecosystem, while growing, is smaller than Prisma's. For teams that think in SQL, this is often the most pragmatic option on the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a SaaS Starter Should Do
&lt;/h2&gt;

&lt;p&gt;A starter should make the default easy without pretending it is universal.&lt;/p&gt;

&lt;p&gt;The docs should answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What database is default?&lt;/li&gt;
&lt;li&gt;How do I run locally without a cloud account?&lt;/li&gt;
&lt;li&gt;How do migrations work before and after production data exists?&lt;/li&gt;
&lt;li&gt;Can I use PostgreSQL?&lt;/li&gt;
&lt;li&gt;If PostgreSQL is supported, which env vars change?&lt;/li&gt;
&lt;li&gt;Which schema file is the source of truth?&lt;/li&gt;
&lt;li&gt;What are the trade-offs?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvuq3oorpdgktulw6v4dq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvuq3oorpdgktulw6v4dq.png" alt="Database docs" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The worst answer is silence. Buyers interpret silence as lock-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration Workflow Matters
&lt;/h2&gt;

&lt;p&gt;For a fresh database, &lt;code&gt;db:push&lt;/code&gt; is convenient.&lt;/p&gt;

&lt;p&gt;For production data, generated migrations are safer.&lt;/p&gt;

&lt;p&gt;That distinction should be explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Fresh local database&lt;/span&gt;
pnpm db:push

&lt;span class="c"&gt;# Production changes&lt;/span&gt;
pnpm db:generate
pnpm db:migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most SaaS teams can start fast and become stricter when real data appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query Code Should Stay Boring
&lt;/h2&gt;

&lt;p&gt;Regardless of provider, application code should import one database client:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/db&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app should not know whether the active provider is Turso or PostgreSQL. Provider selection belongs in database initialization, not in every route handler.&lt;/p&gt;

&lt;p&gt;That boundary matters more than the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Practical Recommendation
&lt;/h2&gt;

&lt;p&gt;If fast local setup and multi-region reads matter most, start with Turso + Drizzle. If your team already knows Postgres, or your customers expect it, use Postgres — with Prisma if you want the generated-client ergonomics, or Drizzle if you'd rather write something closer to SQL. None of these is objectively correct. The mature answer isn't "this database is best" — it's picking a sensible default and documenting the escape hatch.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://codapult.dev" rel="noopener noreferrer"&gt;Codapult&lt;/a&gt; defaults to Turso + Drizzle and documents PostgreSQL via &lt;code&gt;DB_PROVIDER=postgres&lt;/code&gt;; the &lt;a href="https://codapult.dev/docs/database/overview" rel="noopener noreferrer"&gt;database docs&lt;/a&gt; cover setup and migration paths.&lt;/p&gt;

</description>
      <category>database</category>
      <category>typescript</category>
      <category>postgres</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Payment Adapter Pattern in Next.js: Stripe, LemonSqueezy, and Polar Without App-Wide Rewrites</title>
      <dc:creator>Vlad Zoff</dc:creator>
      <pubDate>Tue, 07 Jul 2026 11:33:39 +0000</pubDate>
      <link>https://dev.to/vladzoff/the-payment-adapter-pattern-in-nextjs-stripe-lemonsqueezy-and-polar-without-app-wide-rewrites-1h1p</link>
      <guid>https://dev.to/vladzoff/the-payment-adapter-pattern-in-nextjs-stripe-lemonsqueezy-and-polar-without-app-wide-rewrites-1h1p</guid>
      <description>&lt;p&gt;Most SaaS apps start with one payment provider.&lt;/p&gt;

&lt;p&gt;That is fine. The mistake is letting that provider become the architecture.&lt;/p&gt;

&lt;p&gt;If Stripe-specific objects leak into pages, server actions, admin tools, and webhook handlers, switching to LemonSqueezy or Polar becomes a rewrite. Even adding one-time products next to subscriptions can become awkward.&lt;/p&gt;

&lt;p&gt;The payment adapter pattern keeps provider details behind a small interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shape of the Problem
&lt;/h2&gt;

&lt;p&gt;Payment code tends to sprawl fast: pricing CTAs, authenticated checkout, one-time marketing checkout, billing portal links, webhooks, refunds, seat changes, usage credits. Import a provider SDK directly in even half of these, and every feature becomes provider-aware — which means every provider migration becomes an app-wide grep.&lt;/p&gt;

&lt;p&gt;If each layer imports a provider SDK directly, every feature becomes provider-aware.&lt;/p&gt;

&lt;p&gt;That makes the code harder to test and harder to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Small Interface Is Enough
&lt;/h2&gt;

&lt;p&gt;You do not need a large abstraction. Start with the operations the app actually performs:&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="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;PaymentAdapter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;createCheckout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CheckoutParams&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;createMarketingCheckout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MarketingCheckoutParams&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;createPortalSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;handleWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;WebhookResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;refundPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RefundParams&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RefundResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of the app imports &lt;code&gt;getPaymentAdapter()&lt;/code&gt; instead of &lt;code&gt;stripe&lt;/code&gt;, &lt;code&gt;lemonsqueezy&lt;/code&gt;, or &lt;code&gt;polar&lt;/code&gt;.&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;adapter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPaymentAdapter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;checkoutUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createCheckout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;planId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;monthly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;seats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one boundary gives you room to change providers without changing every caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep Plans Provider-Neutral
&lt;/h2&gt;

&lt;p&gt;Plan definitions should describe your product, not your provider.&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;monthly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;yearly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;190&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unlimited&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;aiCredits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&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="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Provider-specific IDs belong in environment variables or provider mapping code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PAYMENT_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"stripe"&lt;/span&gt;
&lt;span class="nv"&gt;STRIPE_PRICE_PRO_MONTHLY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"price_..."&lt;/span&gt;

&lt;span class="c"&gt;# or&lt;/span&gt;
&lt;span class="nv"&gt;PAYMENT_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"polar"&lt;/span&gt;
&lt;span class="nv"&gt;CHECKOUT_VARIANT_PRO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lets the app reason about &lt;code&gt;pro&lt;/code&gt; while the adapter handles provider IDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Marketing Checkout Is Different
&lt;/h2&gt;

&lt;p&gt;Authenticated SaaS checkout usually attaches a customer, organization, subscription, and return URL.&lt;/p&gt;

&lt;p&gt;Marketing checkout may sell source-code licenses or plugins before the buyer has an account.&lt;/p&gt;

&lt;p&gt;That path needs its own method:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh12s78xtu0k3pvjdpb58.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh12s78xtu0k3pvjdpb58.png" alt="Pricing" width="800" height="500"&gt;&lt;/a&gt;&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMarketingCheckout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;productKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;plugin-ai-kit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;successUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;cancelUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pricing page can route to &lt;code&gt;/api/checkout?product=plugin-ai-kit&lt;/code&gt;, and the route can call the active adapter.&lt;/p&gt;

&lt;p&gt;If you prefer direct hosted checkout URLs, keep those as the simpler path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CHECKOUT_URL_PLUGIN_AI_KIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://provider.example/buy/plugin-ai-kit"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;a href="https://codapult.dev" rel="noopener noreferrer"&gt;Codapult&lt;/a&gt;, &lt;code&gt;CHECKOUT_VARIANT_*&lt;/code&gt; wins over &lt;code&gt;CHECKOUT_URL_*&lt;/code&gt;, so API checkout is used when a provider variant exists and direct links remain available as fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks Need Normalized Events
&lt;/h2&gt;

&lt;p&gt;Provider webhooks are not interchangeable.&lt;/p&gt;

&lt;p&gt;Stripe, LemonSqueezy, and Polar use different event names, payload shapes, signature headers, and product identifiers. The adapter should normalize those into app-level events.&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;type&lt;/span&gt; &lt;span class="nx"&gt;WebhookResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;subscriptionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription.deleted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;subscriptionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payment.refunded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;paymentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ignored&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route validates rate limits and signature, then passes the normalized result to application code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Not to Abstract
&lt;/h2&gt;

&lt;p&gt;Do not hide every provider feature.&lt;/p&gt;

&lt;p&gt;Stripe Connect, LemonSqueezy licensing, and Polar's Merchant of Record model are not identical. If you need a provider-specific feature, keep it explicit at the edge of the adapter.&lt;/p&gt;

&lt;p&gt;Good adapters remove duplication. Bad adapters pretend different products are the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Payoff
&lt;/h2&gt;

&lt;p&gt;With this boundary in place, pages don't import provider SDKs, webhook routes normalize events before they hit application code, and tests mock one interface instead of three. Switching providers becomes a config change and an adapter update — not a rewrite.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://codapult.dev" rel="noopener noreferrer"&gt;Codapult&lt;/a&gt; includes Stripe, LemonSqueezy, and Polar payment adapters; the &lt;a href="https://codapult.dev/docs/payments/overview" rel="noopener noreferrer"&gt;payments docs&lt;/a&gt; show the provider switch and checkout paths.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>stripe</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
