<?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: Shola Jegede</title>
    <description>The latest articles on DEV Community by Shola Jegede (@sholajegede).</description>
    <link>https://dev.to/sholajegede</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1705040%2F3266d189-ef54-4f0e-ae81-ecabae5aac1c.jpg</url>
      <title>DEV Community: Shola Jegede</title>
      <link>https://dev.to/sholajegede</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sholajegede"/>
    <language>en</language>
    <item>
      <title>How to Scope Permissions Per Tenant in a Multi-Tenant App</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Sun, 22 Mar 2026 00:42:57 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-scope-permissions-per-tenant-in-a-multi-tenant-app-27dj</link>
      <guid>https://dev.to/sholajegede/how-to-scope-permissions-per-tenant-in-a-multi-tenant-app-27dj</guid>
      <description>&lt;p&gt;&lt;em&gt;The same user, Sarah, is an admin at Acme Corp and a read-only viewer at Startup Inc. She should see completely different things in the same product depending on which tenant she is operating in. Here is how to build that correctly.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In this article, you will learn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What "per-tenant permissions" actually means and why it is different from global RBAC&lt;/li&gt;
&lt;li&gt;How Kinde models multi-tenancy with organizations, and how tenant context flows into every token&lt;/li&gt;
&lt;li&gt;How to define roles and permissions in Kinde that scope per organization&lt;/li&gt;
&lt;li&gt;How to read tenant-scoped permissions from the Kinde token in a Next.js API route&lt;/li&gt;
&lt;li&gt;How to build a reusable permission guard that you can drop into any route&lt;/li&gt;
&lt;li&gt;How to gate UI components in React based on the current tenant's permissions&lt;/li&gt;
&lt;li&gt;How to isolate data at the database query level using the org code from the token&lt;/li&gt;
&lt;li&gt;How to handle the user-switching-tenants scenario correctly&lt;/li&gt;
&lt;li&gt;The two most common multi-tenant permission bugs and how to avoid them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's dive in!&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Global Permissions
&lt;/h2&gt;

&lt;p&gt;Most developers reach for a simple RBAC model first: define some roles globally, assign users to roles, check roles in your code. Admin can do everything, member can do some things, viewer can only read.&lt;/p&gt;

&lt;p&gt;This works fine for single-tenant apps. It falls apart the moment one user belongs to more than one tenant.&lt;/p&gt;

&lt;p&gt;Consider Sarah. She joined Acme Corp as an admin — she manages billing, invites colleagues, and deletes stale projects. She was also invited to Startup Inc as an outside consultant — read-only access, she can view reports but touch nothing.&lt;/p&gt;

&lt;p&gt;In a global RBAC model, you cannot express this. Sarah has one set of roles in your system. If she is an admin, she is an admin everywhere. If you restrict her globally to viewer, she loses the ability to manage Acme Corp.&lt;/p&gt;

&lt;p&gt;The correct model is tenant-scoped permissions: Sarah's role and permissions are defined per organization. When she logs into Acme Corp, her token says admin with full permissions. When she logs into Startup Inc, the same user's token says viewer with read-only permissions. Same user identity, completely different permission set, determined by which tenant context she authenticated into.&lt;/p&gt;

&lt;p&gt;This is exactly how Kinde handles multi-tenancy, and it is the right mental model for any B2B SaaS product.&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.amazonaws.com%2Fuploads%2Farticles%2Fjzisxw6yxvrwtjmq7var.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.amazonaws.com%2Fuploads%2Farticles%2Fjzisxw6yxvrwtjmq7var.png" alt="User " width="793" height="1004"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How Kinde Models This
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=10&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt;, every customer of your SaaS product is an &lt;strong&gt;organization&lt;/strong&gt;. Acme Corp is one org. Startup Inc is another. Each has its own isolated context — its own members, its own roles assigned to those members, its own feature flags, and optionally its own auth settings.&lt;/p&gt;

&lt;p&gt;When a user logs in, they authenticate within an organization context. Kinde issues a JWT access token that includes the &lt;code&gt;org_code&lt;/code&gt; — the unique identifier for the tenant they are currently operating in. Every permission, every role, every feature flag in that token is scoped to that specific organization.&lt;/p&gt;

&lt;p&gt;The token for Sarah logged into Acme Corp looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kp_123abc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sarah@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"org_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org_acme_corp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rol_001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"admin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Admin"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"projects:create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"projects:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"projects:delete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"billing:manage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"members:invite"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://yourapp.kinde.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1740000000&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same Sarah logged into Startup Inc gets a new token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kp_123abc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sarah@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"org_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org_startup_inc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rol_003"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"viewer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Viewer"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"projects:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"reports:read"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://yourapp.kinde.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1740000000&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;sub&lt;/code&gt;. Different &lt;code&gt;org_code&lt;/code&gt;, &lt;code&gt;roles&lt;/code&gt;, and &lt;code&gt;permissions&lt;/code&gt;. Your application reads these claims and enforces them. Kinde's job is to put the right claims into each token based on what role the user holds in each specific org.&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.amazonaws.com%2Fuploads%2Farticles%2F9j2mxbv233scr7ttenc5.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.amazonaws.com%2Fuploads%2Farticles%2F9j2mxbv233scr7ttenc5.png" alt="Two JWTs side by side, both showing " width="800" height="472"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #1: Define Roles and Permissions in Kinde
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of application code, configure the roles and permissions that express your access model in the Kinde dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defining permissions
&lt;/h3&gt;

&lt;p&gt;Navigate to &lt;strong&gt;Roles &amp;amp; Permissions&lt;/strong&gt; → &lt;strong&gt;Permissions&lt;/strong&gt; → &lt;strong&gt;Add permission&lt;/strong&gt;.&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.amazonaws.com%2Fuploads%2Farticles%2F8iaivk2zosgybepundn4.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.amazonaws.com%2Fuploads%2Farticles%2F8iaivk2zosgybepundn4.png" alt="Kinde dashboard Roles &amp;amp; Permissions &amp;gt; Permissions page showing a list of defined permissions with columns for key and description." width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create one permission per discrete action in your product. The &lt;code&gt;resource:action&lt;/code&gt; naming pattern keeps permissions readable as your product grows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Permission key&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;projects:create&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create new projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;projects:read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;View projects and details&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;projects:delete&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Permanently delete projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;members:invite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Invite new members to the organization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;members:remove&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remove members from the organization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;billing:manage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Manage subscription and billing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reports:read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;View analytics and reports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reports:export&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Export report data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note: Permission keys must be defined centrally in Kinde before they can appear in any token. A permission that is not defined will never show up in the &lt;code&gt;permissions&lt;/code&gt; array, regardless of what roles you assign.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defining roles
&lt;/h3&gt;

&lt;p&gt;Navigate to &lt;strong&gt;Roles &amp;amp; Permissions&lt;/strong&gt; → &lt;strong&gt;Roles&lt;/strong&gt; → &lt;strong&gt;Add role&lt;/strong&gt;.&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.amazonaws.com%2Fuploads%2Farticles%2Fbrlg272n95yvrk9e2j71.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.amazonaws.com%2Fuploads%2Farticles%2Fbrlg272n95yvrk9e2j71.png" alt="Kinde dashboard Roles page showing three roles: Admin (description: " width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the project management example, three roles cover most B2B SaaS access models:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Admin:&lt;/strong&gt; &lt;code&gt;projects:create&lt;/code&gt;, &lt;code&gt;projects:read&lt;/code&gt;, &lt;code&gt;projects:delete&lt;/code&gt;, &lt;code&gt;members:invite&lt;/code&gt;, &lt;code&gt;members:remove&lt;/code&gt;, &lt;code&gt;billing:manage&lt;/code&gt;, &lt;code&gt;reports:read&lt;/code&gt;, &lt;code&gt;reports:export&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Member:&lt;/strong&gt; &lt;code&gt;projects:create&lt;/code&gt;, &lt;code&gt;projects:read&lt;/code&gt;, &lt;code&gt;reports:read&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Viewer:&lt;/strong&gt; &lt;code&gt;projects:read&lt;/code&gt;, &lt;code&gt;reports:read&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;These role-to-permission mappings are what Kinde resolves when building the &lt;code&gt;permissions&lt;/code&gt; array in the user's token. Assign a user the &lt;code&gt;admin&lt;/code&gt; role within a specific org, and every permission attached to that role flows into their token for that org.&lt;/p&gt;

&lt;h3&gt;
  
  
  Assigning roles per organization
&lt;/h3&gt;

&lt;p&gt;When a user joins an organization, they are assigned a role for that organization specifically. Navigate to an organization in Kinde → &lt;strong&gt;Members&lt;/strong&gt; → select a member → assign their role.&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.amazonaws.com%2Fuploads%2Farticles%2Fip7atcktmve98zkf0sjt.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.amazonaws.com%2Fuploads%2Farticles%2Fip7atcktmve98zkf0sjt.png" alt="Kinde dashboard showing an organization's member list with three members: Alice (Admin badge), Bob (Member badge), Sarah (Viewer badge)." width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the per-tenant scoping in action. Sarah's role in this specific organization is Viewer. Navigate to a different organization, find Sarah there, and she might have a completely different role assignment.&lt;/p&gt;

&lt;p&gt;Terrific! Kinde is configured. Time to use these permissions in the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #2: Read Tenant-Scoped Permissions in a Next.js API Route
&lt;/h2&gt;

&lt;p&gt;With the Kinde Next.js SDK installed and configured, reading tenant-scoped permissions server-side requires one import.&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="c1"&gt;// app/api/projects/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getKindeServerSession&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="s2"&gt;@kinde-oss/kinde-auth-nextjs/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getOrganization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getPermissions&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nf"&gt;getKindeServerSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 1: Confirm the user is authenticated&lt;/span&gt;
  &lt;span class="k"&gt;if &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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 2: Get the current tenant context from the token&lt;/span&gt;
  &lt;span class="c1"&gt;// org_code is the tenant identifier — comes from the signed JWT, not the request&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;org&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;getOrganization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No organization context&lt;/span&gt;&lt;span class="dl"&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&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="c1"&gt;// Step 3: Get this user's permissions for this specific org&lt;/span&gt;
  &lt;span class="c1"&gt;// getPermissions() returns { permissions: string[], orgCode: string }&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="p"&gt;}&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;getPermissions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 4: Check the required permission&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:read&lt;/span&gt;&lt;span class="dl"&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;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You do not have permission to view projects&lt;/span&gt;&lt;span class="dl"&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&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="c1"&gt;// Step 5: Fetch data scoped to this tenant&lt;/span&gt;
  &lt;span class="c1"&gt;// ALWAYS use org.orgCode from the token — never a client-supplied tenant ID&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;projects&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;projects&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;Two things to pay attention to here.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;getPermissions()&lt;/code&gt; returns both the &lt;code&gt;permissions&lt;/code&gt; array and the &lt;code&gt;orgCode&lt;/code&gt; — confirming which org these permissions apply to. This is useful for logging and debugging: if a permission check fails unexpectedly, you can verify the org context is what you expected.&lt;/p&gt;

&lt;p&gt;The database query filters by &lt;code&gt;org.orgCode&lt;/code&gt; from the token, not a tenant ID from the request body or query string. The org code in the JWT is cryptographically signed — a client-supplied value is not. This is the fundamental data isolation guarantee of the multi-tenant model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #3: Build a Reusable Permission Guard
&lt;/h2&gt;

&lt;p&gt;Repeating the same auth and permission check logic in every route is tedious and error-prone. Extract it into a single reusable guard function.&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="c1"&gt;// lib/permissions.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getKindeServerSession&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="s2"&gt;@kinde-oss/kinde-auth-nextjs/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Enumerate all permissions in one place — catches typos at compile time&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Permission&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:create&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;members:invite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;members:remove&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;billing:manage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reports:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reports:export&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// What a successful auth context looks like&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AuthContext&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;userId&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;orgCode&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;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Permission&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Checks authentication, org context, and required permissions.
 * Returns AuthContext on success, NextResponse error on failure.
 *
 * Usage:
 *   const auth = await requirePermission("projects:read");
 *   if (auth instanceof NextResponse) return auth;
 *   // auth.orgCode and auth.permissions are now available
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requirePermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Permission&lt;/span&gt;&lt;span class="p"&gt;[]&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;AuthContext&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getOrganization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getPermissions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getUser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nf"&gt;getKindeServerSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nf"&gt;getOrganization&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nf"&gt;getPermissions&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No organization context — authenticate within an organization&lt;/span&gt;&lt;span class="dl"&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasPermission&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;perm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;perm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hasPermission&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Insufficient permissions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;granted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;permissions&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Permission&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every protected API route now becomes a clean two-step: check permissions, run logic.&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="c1"&gt;// app/api/projects/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;requirePermission&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="s2"&gt;@/lib/permissions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// GET — requires projects:read&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&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;auth&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;requirePermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;auth&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;projects&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// tenant-scoped&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;projects&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// POST — requires projects:create&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&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;auth&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;requirePermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:create&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;auth&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;body&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;project&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// stamp the tenant from the token&lt;/span&gt;
      &lt;span class="na"&gt;createdBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&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;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// DELETE — requires projects:delete&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DELETE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;_request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&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="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&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="kr"&gt;string&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auth&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;requirePermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// orgCode in the where clause prevents cross-tenant deletion&lt;/span&gt;
  &lt;span class="c1"&gt;// Even if a wrong ID is passed, this query will not find it&lt;/span&gt;
  &lt;span class="c1"&gt;// unless it belongs to the current user's tenant&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&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="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&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;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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 &lt;code&gt;orgCode&lt;/code&gt; filter on every write and delete is the data isolation layer. It is the database-level defense that ensures even a logic bug cannot cause cross-tenant data leakage.&lt;/p&gt;

&lt;p&gt;Amazing!&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #4: Gate UI Components by Permission
&lt;/h2&gt;

&lt;p&gt;The API layer is the security boundary — it rejects unauthorized requests. The UI layer is the experience boundary — it hides controls the user cannot use, preventing confusion without exposing a wall of 403 errors.&lt;/p&gt;

&lt;p&gt;Create a client-side permission hook:&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="c1"&gt;// hooks/usePermissions.ts&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useKindeBrowserClient&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="s2"&gt;@kinde-oss/kinde-auth-nextjs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Permission&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:create&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;members:invite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;members:remove&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;billing:manage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reports:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reports:export&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;usePermissions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPermission&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getOrganization&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useKindeBrowserClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// getPermission returns { isGranted: boolean, orgCode: string }&lt;/span&gt;
  &lt;span class="c1"&gt;// isGranted is org-scoped — it reflects permissions for the current org context&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;can&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Permission&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;getPermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;isGranted&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&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;org&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getOrganization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;can&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;orgName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the hook in your components for conditional rendering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// components/ProjectCard.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;usePermissions&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="s2"&gt;@/hooks/usePermissions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProjectCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Project&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;can&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;usePermissions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"project-card"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"project-actions"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Members and admins can edit */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:create&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;editProject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Edit&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;

        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Only admins can delete — not rendered at all for members/viewers */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
            &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;deleteProject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"destructive"&lt;/span&gt;
          &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            Delete
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// components/Sidebar.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;usePermissions&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="s2"&gt;@/hooks/usePermissions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Link&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Sidebar&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;can&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orgName&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;usePermissions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;nav&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sidebar"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Show current org context at the top */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;orgName&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"workspace-name"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;orgName&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;

      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Always visible to authenticated org members */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/projects"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Projects&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Only visible if user has reports:read */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reports:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/reports"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Reports&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;

      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Only visible if user can invite members (admin) */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;members:invite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/members"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Team Members&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;

      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Only visible if user can manage billing (admin) */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;billing:manage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/settings/billing"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Billing&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;nav&lt;/span&gt;&lt;span class="p"&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 same component file produces three completely different outputs&lt;br&gt;
depending on who is logged in:&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.amazonaws.com%2Fuploads%2Farticles%2Fu3deocq7moytx7cvrxcy.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.amazonaws.com%2Fuploads%2Farticles%2Fu3deocq7moytx7cvrxcy.png" alt="Admin view — full sidebar with Projects, Reports, Team Members,&amp;lt;br&amp;gt;
and Billing. Cards show both Edit and Delete buttons.&amp;lt;br&amp;gt;
Badge shows Admin · Sarah Chen" width="800" height="501"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2F4xwvjye0hfu6m9qeuncx.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.amazonaws.com%2Fuploads%2Farticles%2F4xwvjye0hfu6m9qeuncx.png" alt="Member view — sidebar shows Projects and Reports only.&amp;lt;br&amp;gt;
Cards show Edit button only, no Delete.&amp;lt;br&amp;gt;
Badge shows Member · Sarah Chen" width="800" height="501"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2F51bzevuawixfug14u15m.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.amazonaws.com%2Fuploads%2Farticles%2F51bzevuawixfug14u15m.png" alt="Viewer view — sidebar shows Projects only.&amp;lt;br&amp;gt;
Cards show No actions available — no buttons at all.&amp;lt;br&amp;gt;
Badge shows Viewer · Sarah Chen" width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note: Conditional rendering hides controls but does not enforce access. A user can still call API routes directly with tools like curl. The &lt;code&gt;requirePermission&lt;/code&gt; guard in the API layer is what enforces authorization — the UI gate is for experience, not security.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step #5: Handle Tenant Switching Correctly
&lt;/h2&gt;

&lt;p&gt;When Sarah switches from Acme Corp to Startup Inc, she needs a new token scoped to the new organization. The existing token's &lt;code&gt;org_code&lt;/code&gt; is &lt;code&gt;org_acme_corp&lt;/code&gt; — you cannot change a signed JWT's claims without issuing a new one.&lt;/p&gt;

&lt;p&gt;The correct approach is to redirect the user through Kinde's authentication with the target org specified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// components/OrgSwitcher.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useKindeBrowserClient&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="s2"&gt;@kinde-oss/kinde-auth-nextjs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;LoginLink&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="s2"&gt;@kinde-oss/kinde-auth-nextjs/components&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Org&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;code&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;name&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;OrgSwitcher&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orgs&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;orgs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Org&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getOrganization&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useKindeBrowserClient&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;currentOrg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getOrganization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"org-switcher"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;orgs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;currentOrg&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="c1"&gt;// Already in this org — highlight it as current&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"org-item current"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"badge"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Current&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&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="c1"&gt;// Switch to this org — triggers a new Kinde token for org.code&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LoginLink&lt;/span&gt;
            &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;authUrlParams&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;org_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"org-item"&lt;/span&gt;
          &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;LoginLink&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&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 &lt;code&gt;org_code&lt;/code&gt; parameter in &lt;code&gt;authUrlParams&lt;/code&gt; tells Kinde which organization to issue the token for. After the brief authentication redirect, the user's token contains the &lt;code&gt;org_code&lt;/code&gt;, &lt;code&gt;roles&lt;/code&gt;, and &lt;code&gt;permissions&lt;/code&gt; for the switched-to org. Every permission check in both the API and the UI immediately reflects the new tenant context.&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.amazonaws.com%2Fuploads%2Farticles%2F41y3hw4hwwz0uycbm92n.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.amazonaws.com%2Fuploads%2Farticles%2F41y3hw4hwwz0uycbm92n.png" alt="Org switching flow — " width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It All Together
&lt;/h2&gt;

&lt;p&gt;Here is the complete per-tenant permission system mapped across all layers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kinde (config)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Defines permissions globally, bundles them into roles, assigns roles per org&lt;/td&gt;
&lt;td&gt;Kinde dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JWT token&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Carries &lt;code&gt;org_code&lt;/code&gt; + &lt;code&gt;permissions&lt;/code&gt; for the current org context&lt;/td&gt;
&lt;td&gt;Issued by Kinde, verified by SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;requirePermission()&lt;/code&gt; (API)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Rejects requests missing auth, org context, or required permissions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lib/permissions.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DB queries&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Filter every read/write/delete by &lt;code&gt;auth.orgCode&lt;/code&gt; from the token&lt;/td&gt;
&lt;td&gt;All API route handlers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;usePermissions()&lt;/code&gt; hook (UI)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Conditionally renders controls based on &lt;code&gt;getPermission(key).isGranted&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;React components&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;OrgSwitcher&lt;/code&gt; (UI)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Issues a new token for the selected org via &lt;code&gt;LoginLink&lt;/code&gt; + &lt;code&gt;org_code&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;OrgSwitcher&lt;/code&gt; component&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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.amazonaws.com%2Fuploads%2Farticles%2Fvohduygmjntuqf34g21z.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.amazonaws.com%2Fuploads%2Farticles%2Fvohduygmjntuqf34g21z.png" alt="Full architecture — Kinde (left box: permissions → roles → orgs with role assignments) issues a signed JWT → the JWT flows to both the API Layer (center box: requirePermission guard → DB queries filtered by orgCode) and the React Layer (right box: usePermissions hook → conditional rendering)." width="800" height="276"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Most Common Multi-Tenant Permission Bugs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug #1: Trusting the client-supplied tenant ID&lt;/strong&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="c1"&gt;// ❌ Wrong — org code from the request body is attacker-controlled&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="p"&gt;}&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;projects&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// a user can pass any org's code here&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Correct — org code from the signed JWT cannot be forged&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&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;auth&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;requirePermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;auth&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;projects&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A user controls what goes in a request body. They cannot forge a claim in a signed JWT. Always read the tenant context from the token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug #2: Checking role names instead of permissions&lt;/strong&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="c1"&gt;// ❌ Wrong — brittle, breaks when roles are renamed or restructured&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;roles&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;getRoles&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;isAdmin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;forbidden&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Correct — checks the specific action, decoupled from role names&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auth&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;requirePermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects:delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Role names can change. New roles can be created. A "super_admin" or "owner" role might need delete access too. When you check role names, you have to update every check site when your role structure evolves. When you check permissions, the mapping from roles to permissions is managed in Kinde's configuration and your code stays unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In this article, you built a complete per-tenant permission system for a multi-tenant Next.js app using Kinde organizations. The same user now has completely different permissions depending on which org they are operating in — because those permissions come from the org-scoped token claims, not from a global role attached to the user.&lt;/p&gt;

&lt;p&gt;The model is clear: Kinde puts the right claims in the token for each org context (trust layer). Your API guard enforces permissions before any logic runs (security layer). Your UI hook hides controls the user cannot use (experience layer). Your database queries filter by the org code from the token (isolation layer).&lt;/p&gt;

&lt;p&gt;Four layers, one source of truth: the token.&lt;/p&gt;

&lt;p&gt;Kinde is free for up to 10,500 monthly active users, no credit card required. Create your account at &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=10&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;kinde.com&lt;/a&gt; and have multi-tenant permissions running today.&lt;/p&gt;

</description>
      <category>kinde</category>
      <category>webdev</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Free Trial Isn't Converting Because Your Upgrade Flow Has Too Much Friction</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Sun, 22 Mar 2026 00:27:29 +0000</pubDate>
      <link>https://dev.to/sholajegede/your-free-trial-isnt-converting-because-your-upgrade-flow-has-too-much-friction-1e9j</link>
      <guid>https://dev.to/sholajegede/your-free-trial-isnt-converting-because-your-upgrade-flow-has-too-much-friction-1e9j</guid>
      <description>&lt;p&gt;&lt;em&gt;A technical founder asked me why only 8% of their trial users were upgrading. We spent an hour going through their funnel. The product was good. The problem was everything around it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In this article, you will learn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why most free trial conversion problems are not actually product problems&lt;/li&gt;
&lt;li&gt;What the data says about where trial users actually drop off&lt;/li&gt;
&lt;li&gt;The five friction points that kill conversion — and which ones founders keep building themselves&lt;/li&gt;
&lt;li&gt;Why auth and upgrade flows are the worst place to build from scratch&lt;/li&gt;
&lt;li&gt;How Kinde handles the login, onboarding, upgrade, and access layers so you do not have to&lt;/li&gt;
&lt;li&gt;What your team should be spending its friction-reduction energy on instead&lt;/li&gt;
&lt;li&gt;How to audit your own funnel in 30 minutes and find the leaks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's dive in!&lt;/p&gt;

&lt;h2&gt;
  
  
  The Conversation That Prompted This Article
&lt;/h2&gt;

&lt;p&gt;A technical founder — building a SaaS tool for operations teams — reached out with a familiar problem. Their trial-to-paid conversion was hovering around 8%. They had good traffic, decent trial signups, and they knew from user interviews that people liked the product. But when the trial ended, most users just did not upgrade.&lt;/p&gt;

&lt;p&gt;They had already done the obvious things. They had shortened the trial from 30 days to 14. They had added in-app upgrade prompts. They had set up reminder emails. The conversion number barely moved.&lt;/p&gt;

&lt;p&gt;When we walked through their full funnel together, the product was not the problem. The product did what it promised. But surrounding it was a web of friction that accumulated across every touchpoint — friction that the founding team had built themselves, piece by piece, never stepping back to look at the whole thing from a new user's perspective.&lt;/p&gt;

&lt;p&gt;The login flow asked for email, password, first name, last name, company name, and team size before the user saw a single feature. The upgrade page required re-entering credit card information even if the user had already paid for something. The "upgrade to Pro" button existed in exactly one place in the entire product. Inviting a team member required the admin to first create the team member's role, then generate an invite link, then email it externally, then wait for the user to set up their own password. None of these problems were product problems. They were infrastructure problems. And the founding team had built every one of them.&lt;/p&gt;

&lt;p&gt;This article is about that category of friction: the stuff around your product that you probably built yourself and never had to build at all.&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.amazonaws.com%2Fuploads%2Farticles%2Fxdg30r0zjgzk832pz9eo.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.amazonaws.com%2Fuploads%2Farticles%2Fxdg30r0zjgzk832pz9eo.png" alt="A funnel showing a typical SaaS trial journey with friction points marked at each stage — Signup (form fields), First login (email verification delay), Onboarding (required setup before seeing value), Upgrade prompt (single location, one path), Payment (re-entry, redirect), Invite teammate (multi-step manual flow)." width="560" height="2480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Data Actually Says
&lt;/h2&gt;

&lt;p&gt;Before diagnosing where friction hides, it is worth grounding this in numbers.&lt;/p&gt;

&lt;p&gt;Opt-in free trials — the model where users sign up without a credit card — convert to paid at around 18 to 25% on average. Freemium products, where users stay on a free tier indefinitely, convert around 2.6 to 5%. If your product is genuinely solving a real problem and your conversion rate is sitting well below these benchmarks, you almost certainly have a friction problem, not a product problem.&lt;/p&gt;

&lt;p&gt;A few statistics that should make every technical founder uncomfortable:&lt;/p&gt;

&lt;p&gt;Each additional field in a signup form reduces completion by an average of 5 to 7%. When one company dropped the credit card requirement from their signup, they immediately saw a 71% increase in users willing to start a trial. Removing just three form fields from a payment page lifted conversion by 8%. Users who experience core product value within the first 15 minutes are three times more likely to retain than those who wait 30 minutes or longer. Users who complete an onboarding flow are five times more likely to convert than those who do not.&lt;/p&gt;

&lt;p&gt;The pattern is consistent: every second between a user's decision to try your product and the moment they experience its value is a second where they can change their mind. And the irony is that the friction accumulates most heavily in the parts of the product that have nothing to do with what makes your product valuable.&lt;/p&gt;

&lt;p&gt;Nobody's aha moment is "I successfully created an account." Nobody upgrades to Pro because "the payment page was really elegant." But a bad login experience, a broken invite flow, or an upgrade page that sends the user off-site and back can absolutely stop a conversion that was about to happen.&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.amazonaws.com%2Fuploads%2Farticles%2Furul1zcb2b8jptrehvlz.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.amazonaws.com%2Fuploads%2Farticles%2Furul1zcb2b8jptrehvlz.png" alt="A simple comparison card with two columns — " width="800" height="791"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Five Friction Points That Kill Free Trial Conversion
&lt;/h2&gt;

&lt;p&gt;These are the five places where trial conversion typically bleeds out. Two of them are product problems you genuinely need to solve. Three of them are infrastructure problems you should never have had to build.&lt;/p&gt;

&lt;h3&gt;
  
  
  Friction Point #1: The Signup Wall (Infrastructure)
&lt;/h3&gt;

&lt;p&gt;The most common version of this: a new user lands on your product, decides they want to try it, and is immediately presented with a form. Email. Password. Confirm password. First name. Last name. Company. Team size. Job title. Use case. Newsletter opt-in. Terms checkbox.&lt;/p&gt;

&lt;p&gt;This is not onboarding. This is interrogation. And it happens at exactly the wrong moment — before the user has experienced a single second of your product's value.&lt;/p&gt;

&lt;p&gt;The right version of signup asks for as little as possible. An email address and a way to verify it is enough. Better yet, a single "Sign in with Google" button is one click. Users who have already experienced your product and then are asked for their email to save their work convert significantly better than users asked to commit before they have seen anything.&lt;/p&gt;

&lt;p&gt;This is a pure infrastructure problem. It is not about your product. It is about how you authenticate users. And it is completely solved by not building your own auth system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Friction Point #2: Email Verification Delay (Infrastructure)
&lt;/h3&gt;

&lt;p&gt;The single most underestimated conversion killer in SaaS. User signs up, checks email, verification email is in spam or takes 90 seconds, user moves on and never comes back.&lt;/p&gt;

&lt;p&gt;Email verifications that go to spam folders, that arrive late, or that require the user to leave the product and return to it introduce a break in momentum that most users never recover from. The user was ready to try your product. Now they are checking their spam folder.&lt;/p&gt;

&lt;p&gt;The fix is passwordless authentication with email OTPs — users enter their email, get a short code, enter it, and they are in. No password to create, no verification link to chase, no spam filter to defeat. Kinde's default authentication is email + code, not a magic link. A six-digit OTP that the user enters manually. The user stays in the flow the entire time. No tab-switching.&lt;/p&gt;

&lt;h3&gt;
  
  
  Friction Point #3: The First-Run Empty State (Product)
&lt;/h3&gt;

&lt;p&gt;This is a genuine product problem — and arguably the most important one to solve, because it sits right after the authentication friction that you have just removed.&lt;/p&gt;

&lt;p&gt;A new user logs in for the first time and sees a completely blank dashboard. No data, no examples, no clear next action. The product looks empty because it is empty. This is the "blank canvas problem" and it is responsible for an enormous share of early churn.&lt;/p&gt;

&lt;p&gt;The fix is not complex: pre-populate the user's account with example data, a sample project, or a guided first action that gets them to their aha moment within the first five minutes. Trello puts users directly into a pre-configured board. Figma opens a default file. Notion creates a sample workspace. None of them leave the user staring at an empty screen wondering what to do.&lt;/p&gt;

&lt;p&gt;This is the friction point that is worth spending your engineering team's time on. It is specific to your product's value proposition and cannot be handled by any external tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Friction Point #4: The Upgrade Moment (Infrastructure)
&lt;/h3&gt;

&lt;p&gt;This is where the founder I talked to lost most of their conversions. The user has been using the product for a week. They have hit a feature limit or they have decided the product is worth paying for. They look for the upgrade button.&lt;/p&gt;

&lt;p&gt;It is not in the navigation. It is not in the settings. It is not in the feature they just hit the limit on. It is on a pricing page linked from the marketing site footer.&lt;/p&gt;

&lt;p&gt;Even when users find the upgrade path, the friction continues: they are redirected to a separate page, asked to choose a plan, asked to enter payment details, redirected to a third-party checkout, and then redirected back to the product. At each hop, some percentage of users drop off.&lt;/p&gt;

&lt;p&gt;The right upgrade flow is in-context: the upgrade prompt appears exactly where the user hits the limit, the payment is handled without leaving the product, and the new feature is unlocked immediately after payment with zero additional steps required.&lt;/p&gt;

&lt;p&gt;This is almost entirely an infrastructure problem. Wiring together your auth system, your plan state, your billing provider, and your feature flags in a way that makes this experience seamless is weeks of engineering work. Or it is a Kinde configuration that takes an afternoon.&lt;/p&gt;

&lt;h3&gt;
  
  
  Friction Point #5: The Team Invite Friction (Infrastructure and Product)
&lt;/h3&gt;

&lt;p&gt;Many SaaS products live or die on team adoption. A single user converts to paid, but the product is much stickier once teammates are inside it too. This is the expansion motion that drives net revenue retention above 100%.&lt;/p&gt;

&lt;p&gt;But the invite flow in most early-stage products is brutal. Admin has to set up roles. Generate an invite link. Copy it. Email it manually. The invitee signs up, creates a password, and waits for an admin to approve. The teammate who signed up on Tuesday shows up in the admin's queue on Thursday. The admin gets an email notification, clicks through, approves, and the new user can finally access the product.&lt;/p&gt;

&lt;p&gt;Three days of friction for what should be a one-minute experience.&lt;/p&gt;

&lt;p&gt;The right invite flow sends an invitation email with a single link. The invitee clicks the link, authenticates in one step (social login or OTP), and is inside the product with the correct role already assigned. No manual queue. No password creation. No admin approval step for standard roles.&lt;/p&gt;

&lt;p&gt;Again: this is infrastructure. The invitation, authentication, role assignment, and access provisioning are not features of your product. They are plumbing that most teams spend weeks building.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Category of Friction You Should Stop Building
&lt;/h2&gt;

&lt;p&gt;Notice the pattern across friction points #1, #2, #4, and #5. They are all infrastructure. They are all part of the login and access layer. And they are all things that the founding team of the product I described above had built themselves, from scratch, because that seemed like the right call at the time.&lt;/p&gt;

&lt;p&gt;It was not the right call. Here is why.&lt;/p&gt;

&lt;p&gt;When you build your own auth system, you are making a bet that your implementation of signup flows, email verification, social login, OTP delivery, password reset, session management, multi-tenant org logic, role assignment, invite flows, plan gating, and upgrade checkout will be good enough not to cost you conversions. That bet almost always loses, for a simple reason: none of these things are your product. You are not optimizing them. You are not testing them. You are not studying the best practices the way Stripe studied payment UX or the way Kinde has studied auth UX.&lt;/p&gt;

&lt;p&gt;Every hour your team spends debugging email deliverability or adding a Google login button or wiring up a Stripe webhook to update a user's plan state in your database is an hour not spent on friction point #3 — the blank canvas problem, which is the one that is genuinely specific to your product and genuinely worth your team's engineering attention.&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.amazonaws.com%2Fuploads%2Farticles%2F68fudatz734py871aj5a.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.amazonaws.com%2Fuploads%2Farticles%2F68fudatz734py871aj5a.png" alt="A simple " value="" width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Kinde Gets Right Out of the Box
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=11&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; is auth, user management, and billing in one platform. The reason this matters for free trial conversion is not because Kinde does those things — it is that Kinde does those things well, so you do not have to.&lt;/p&gt;

&lt;p&gt;Here is what you get without writing any infrastructure code:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passwordless authentication by default.&lt;/strong&gt; Kinde's default auth method is email + OTP, not a password. Users enter their email and a six-digit code. No password to create, no verification link to follow, no confirmation email that might land in spam. Users stay in your product from the moment they decide to sign up.&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.amazonaws.com%2Fuploads%2Farticles%2Fjfuukp5zkqzo238sfz3n.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.amazonaws.com%2Fuploads%2Farticles%2Fjfuukp5zkqzo238sfz3n.png" alt="Kinde's default login/signup page showing the email field and OTP code entry — clean, minimal, no password field." width="800" height="501"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2Fjl6otjk3ddnejib1oz05.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.amazonaws.com%2Fuploads%2Farticles%2Fjl6otjk3ddnejib1oz05.png" alt="This is what users see when they first encounter your product's auth layer." width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Social login out of the box.&lt;/strong&gt; Google, GitHub, Microsoft, Apple, and others can be enabled in the Kinde dashboard in under two minutes. One-click signup removes all the form-field friction from new user acquisition. Users who already have a Google account can be inside your product in a single click.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No credit card required for Kinde itself.&lt;/strong&gt; Kinde's free plan supports up to 10,500 monthly active users — no credit card required to get started. This is important because it means you can mirror the same no-credit-card opt-in model for your own users that the data shows converts best, without paying anything for the auth infrastructure that enables it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upgrade flows that stay in-product.&lt;/strong&gt; Kinde's billing integration puts the pricing table, plan selection, and Stripe checkout inside your product. When a user hits a feature limit and sees an upgrade prompt, they complete the upgrade without leaving the product. The new plan is active immediately. Feature flags tied to that plan update in the same request.&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.amazonaws.com%2Fuploads%2Farticles%2Fw0nwv1c981grmwd0kz71.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.amazonaws.com%2Fuploads%2Farticles%2Fw0nwv1c981grmwd0kz71.png" alt="Kinde's plan selection UI — Free and Pro cards with " width="800" height="633"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feature flags tied to plan state.&lt;/strong&gt; The moment a user upgrades, the feature flags attached to that plan activate without any webhook handling or database updates from your team. The user does not have to log out and back in. The feature is simply available. This is what makes in-context upgrade prompts work: "You've hit the limit on the free plan. Upgrade to Pro to unlock this." The user clicks, pays, and the feature unlocks in that same session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Organization-based team management.&lt;/strong&gt; For B2B products, Kinde organizations give every customer their own isolated workspace, with their own members, roles, and plan state. The invite flow sends a Kinde-powered email with a join link. The invitee authenticates (social login or OTP), and their role in the organization is already assigned. No admin queue, no password creation ceremony, no manual step for the inviting user after they send the invitation.&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.amazonaws.com%2Fuploads%2Farticles%2Faan4s0ug775m96n16jqy.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.amazonaws.com%2Fuploads%2Farticles%2Faan4s0ug775m96n16jqy.png" alt="Kinde dashboard showing the Organization view — expanded to show member list with roles (Admin, Member, Viewer) and an " width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Roles and permissions that express in your product's UI.&lt;/strong&gt; Using &lt;code&gt;getPermissions()&lt;/code&gt; or feature flag checks from the Kinde SDK, your product can conditionally show or hide features based on a user's role and plan state. An admin on the Pro plan sees everything. A member on the free plan sees what they should see. This all comes from the Kinde token — no database query required to determine what a user can see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying This to Your Own Funnel
&lt;/h2&gt;

&lt;p&gt;The founder I described at the start ended up migrating to Kinde and rebuilding their auth and upgrade layer on top of it. The signup form went from six fields to one. Social login accounted for 60% of new signups within the first two weeks. The upgrade flow moved inside the product. The invite flow dropped from a five-step manual process to a single email.&lt;/p&gt;

&lt;p&gt;Their trial-to-paid conversion went from 8% to 21% over the following 90 days.&lt;/p&gt;

&lt;p&gt;That is not a product change. The product did not change. The friction around the product changed.&lt;/p&gt;

&lt;p&gt;If you want to audit your own funnel, here is a 30-minute process:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 (5 min): Walk the signup flow yourself.&lt;/strong&gt; Open an incognito window and sign up for your own product. Count every field, every click, every page navigation, every email you have to check. Every step is potential churn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 (5 min): Measure time to first value.&lt;/strong&gt; From the moment you complete signup to the moment you see something genuinely useful in the product — how long does that take? If it is more than five minutes, you have a problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 (5 min): Find the upgrade path.&lt;/strong&gt; Without knowing where it is, try to find the upgrade button. Time yourself. If it takes more than 30 seconds, most users will not find it at the moment they are ready to pay.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 (5 min): Walk the invite flow.&lt;/strong&gt; Go through the process of inviting a team member. Count every step the inviting user has to take. Then count every step the invited user has to take. Multiply both by the cognitive overhead of switching contexts and waiting for emails. That is your expansion friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5 (10 min): Look at your drop-off data.&lt;/strong&gt; Where in the funnel are users leaving? If the biggest drop is between signup and first meaningful action, you have an onboarding problem. If the biggest drop is at the end of the trial, you likely have an upgrade friction problem. If users activate but do not invite teammates, you have an expansion friction problem.&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.amazonaws.com%2Fuploads%2Farticles%2Fzxr6m9t7ejgrwnyndhnv.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.amazonaws.com%2Fuploads%2Farticles%2Fzxr6m9t7ejgrwnyndhnv.png" alt="A funnel audit template — five numbered rows (Signup Flow, Time to First Value, Upgrade Path, Invite Flow, Drop-off Data) with columns for " width="700" height="3460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Division of Labor That Works
&lt;/h2&gt;

&lt;p&gt;The founders who convert at 20%+ typically have an implicit understanding of this division:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kinde's job:&lt;/strong&gt; Handle every interaction a user has with authentication, access, plan state, and team membership. Make these interactions fast, polished, and invisible. No friction that the user notices, no engineering hours that the team notices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your team's job:&lt;/strong&gt; Handle every interaction a user has with your actual product. Make the first-run experience genuinely delightful. Get users to their aha moment in under five minutes. Build the features that make people want to upgrade because the product is worth it — not because the upgrade flow tricked them into it.&lt;/p&gt;

&lt;p&gt;This is the right division. The user's experience of your auth layer is not a differentiator. Nobody upgrades to Pro because your login page was beautiful. But users will not upgrade at all if the upgrade path is hard to find, the payment flow is clunky, or the new features do not unlock immediately after payment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=11&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; handles the layer that should be invisible and frictionless. Your team handles the layer that should be delightful and differentiated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In this article, you audited the five friction points that kill free trial conversion, identified which ones are infrastructure problems you should stop building from scratch, and saw how Kinde handles the login, onboarding, upgrade, and team access layers so that your engineering team can focus on what actually makes your product worth paying for.&lt;/p&gt;

&lt;p&gt;The founder's 8% conversion rate was not a product failure. It was a resource allocation failure. Every hour spent debugging email verification or building invite flows is an hour taken from the blank canvas problem — the one genuine product problem that only your team can solve.&lt;/p&gt;

&lt;p&gt;Free up those hours. Let Kinde handle the plumbing. Spend your engineering attention on the aha moment.&lt;/p&gt;

&lt;p&gt;Kinde is free for up to 10,500 monthly active users, no credit card required. Create your account at &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=11&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;kinde.com&lt;/a&gt; and have authentication running before lunch.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>kinde</category>
      <category>architecture</category>
      <category>development</category>
    </item>
    <item>
      <title>How to Build a Secure MCP Server for AI Agents</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Sun, 22 Mar 2026 00:15:55 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-build-a-secure-mcp-server-for-ai-agents-551g</link>
      <guid>https://dev.to/sholajegede/how-to-build-a-secure-mcp-server-for-ai-agents-551g</guid>
      <description>&lt;p&gt;&lt;em&gt;OpenClaw proved that millions of developers will build with MCP first and think about security second. Here is how to not be that developer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In this article, you will learn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What OpenClaw and Moltbook revealed about the state of agentic AI security&lt;/li&gt;
&lt;li&gt;Why most MCP servers are architecturally insecure by default&lt;/li&gt;
&lt;li&gt;What "tool-level auth" means and why it is the right mental model for MCP security&lt;/li&gt;
&lt;li&gt;How Kinde's own MCP server (shipped January 2026) handles scoped access as a real-world reference&lt;/li&gt;
&lt;li&gt;How to set up a Kinde M2M application as the authorization layer for your MCP server&lt;/li&gt;
&lt;li&gt;How to issue and validate scoped JWT tokens per tool category&lt;/li&gt;
&lt;li&gt;How to write a Node.js MCP server that enforces permissions at the tool boundary&lt;/li&gt;
&lt;li&gt;How to test that your auth layer actually holds under adversarial conditions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's dive in!&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenClaw, Moltbook, and the Security Wake-Up Call
&lt;/h2&gt;

&lt;p&gt;In late January 2026, two things happened simultaneously that changed how the developer community thinks about agentic AI security.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenClaw&lt;/strong&gt; — an open-source self-hosted AI agent formerly called Clawdbot and briefly Moltbot — crossed 180,000 GitHub stars in a week. It turned any messaging app (WhatsApp, Telegram, Discord, Signal) into an AI agent interface, extended by a Skills system built entirely on MCP servers. Security researchers scanning the internet found over 1,800 exposed OpenClaw instances leaking API keys, chat histories, and account credentials. Cisco's AI security team tested a third-party OpenClaw skill from the ClawHub marketplace and found it performed data exfiltration and prompt injection without user awareness. One of OpenClaw's own maintainers, known as Shadow, warned on Discord that the project was "far too dangerous" for anyone who could not understand command-line basics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Moltbook&lt;/strong&gt; — a Reddit-style social network built by Matt Schlicht exclusively for AI agents — launched on January 28 and hit 1.6 million registered agents by February. It was built via vibe coding and contained a misconfigured Supabase database that granted full read and write access to its data — exposing 1.5 million agents belonging to only 17,000 registered humans. Cybersecurity researchers at Vectra AI and PointGuard AI identified Moltbook as a vector for indirect prompt injection: a malicious post on Moltbook could cascade into every MCP tool an agent had access to. Moltbook was acquired by Meta on March 10, 2026.&lt;/p&gt;

&lt;p&gt;The pattern that connected both incidents was the same: agents were granted sweeping, unscoped access to tools. No authentication. No per-tool permissions. No way to answer the question "which agent did this and what were they authorized to do?" A static API key — if there was one at all — gave full access to everything.&lt;/p&gt;

&lt;p&gt;Security researcher Itamar Golan (founder of Prompt Security, now part of SentinelOne) put it plainly in a VentureBeat interview: treat agents as production infrastructure, not a productivity app — least privilege, scoped tokens, allowlisted actions, strong authentication on every integration, and auditability end-to-end.&lt;/p&gt;

&lt;p&gt;This article is about building exactly that.&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.amazonaws.com%2Fuploads%2Farticles%2Fhlo3q1atq8ylnh9wge9v.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.amazonaws.com%2Fuploads%2Farticles%2Fhlo3q1atq8ylnh9wge9v.png" alt="Timeline showing OpenClaw going viral (Jan 2026) → Moltbook launching (Jan 28) → 1,800+ exposed OpenClaw instances found → Moltbook Supabase breach → Meta acquires Moltbook (Mar 10)." width="639" height="311"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Most MCP Servers Are Architecturally Insecure
&lt;/h2&gt;

&lt;p&gt;The MCP spec (finalized in late 2024, updated through 2025) mandates OAuth 2.1 with PKCE for public remote servers. But the ecosystem reality is starkly different. A 2026 survey of production MCP servers found that 53% rely on static API keys and only 8.5% use OAuth. The rest have no authentication at all.&lt;/p&gt;

&lt;p&gt;This is not because developers are careless. It is because the default path is the insecure path. The most commonly copied MCP server examples use static bearer tokens or no auth. The "get something working" version of MCP has no concept of which agent is calling or what it is allowed to do. And when 180,000 developers adopt OpenClaw and start wiring up Skills, they inherit those insecure defaults at scale.&lt;/p&gt;

&lt;p&gt;The specific problems with unscoped MCP access are worth naming precisely, because they map directly to what happened with OpenClaw and Moltbook:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No tool-level permissions.&lt;/strong&gt; A token that grants access to your MCP server grants access to every tool on it. An agent authorized to read files can also delete them. An agent authorized to query a database can also drop tables. There is no granularity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No agent identity.&lt;/strong&gt; When five different OpenClaw instances connect to your MCP server, can you tell them apart? Can you see which user delegated authority to each one? With a static API key, the answer is no. All you know is that something with the key made a request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt injection cascades.&lt;/strong&gt; Because agents are granted broad tool access, a successful prompt injection attack has a large blast radius. The Moltbook incident demonstrated this: a malicious post in an agent's context could instruct the agent to call tools it had no business calling — and because tools were unscoped, the agent could comply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No revocation granularity.&lt;/strong&gt; If you need to cut off one agent's access, you have to rotate the key for everyone.&lt;/p&gt;

&lt;p&gt;What you need instead is a model where each agent has a cryptographically verified identity, each token carries specific scopes that enumerate exactly which tools the agent can call, and every tool call is validated against those scopes before execution. That is what Kinde's own MCP server — shipped in January 2026 — demonstrates in production.&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.amazonaws.com%2Fuploads%2Farticles%2Fkwed0y5nvy4crdpwx0kh.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.amazonaws.com%2Fuploads%2Farticles%2Fkwed0y5nvy4crdpwx0kh.png" alt="Two architecture diagrams side by side. LEFT: " width="800" height="274"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Kinde MCP Server as a Reference Model
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=9&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; shipped its own MCP server in January 2026. It lets AI assistants like Claude Code connect to your Kinde business and manage users, organizations, roles, and permissions through natural language — things like "create a new organization for Acme Corp" or "list all users with the admin role."&lt;/p&gt;

&lt;p&gt;The security model it uses is the right reference for anyone building their own MCP server:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;M2M (Machine-to-Machine) applications&lt;/strong&gt; are the identity primitive for agents. Not user accounts. Not API keys. Dedicated M2M applications that have their own client ID and secret, exist as first-class entities in Kinde, and can be granted or revoked access independently of human users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scopes enumerate exact permissions.&lt;/strong&gt; The Kinde MCP server defines specific scopes for each category of operation — scopes for reading users, different scopes for writing users, separate scopes for managing organizations. An M2M application that has not been granted a scope cannot call the tools that require it. The authorization decision happens at the token issuance level, not at runtime guesswork.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JWT validation on every request.&lt;/strong&gt; Tokens are short-lived (one hour by default for M2M), signed with Kinde's private key, and verified against Kinde's public JWKS endpoint on every inbound request. A token cannot be forged, replayed beyond its expiry, or accepted by a different server than the one it was issued for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revocability.&lt;/strong&gt; To cut off an agent's access, you revoke or remove the M2M application in Kinde. It stops working immediately. No key rotation, no impact on other agents.&lt;/p&gt;

&lt;p&gt;This is the pattern you should apply to your own MCP server. The steps below show you exactly how to implement it.&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.amazonaws.com%2Fuploads%2Farticles%2Fhhfk6dtgrn5917f2hq35.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.amazonaws.com%2Fuploads%2Farticles%2Fhhfk6dtgrn5917f2hq35.png" alt="Kinde dashboard MCP Server section — showing the setup page or the operations/scopes list that Kinde's own MCP server exposes (e.g., list_users scope, create_organization scope, manage_roles scope)." width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #1: Create a Kinde M2M Application for Your MCP Server
&lt;/h2&gt;

&lt;p&gt;Before writing any server code, set up the authorization layer in Kinde. This takes about three minutes.&lt;/p&gt;

&lt;p&gt;Create a free Kinde account at &lt;a href="https://kinde.com" rel="noopener noreferrer"&gt;kinde.com&lt;/a&gt; if you do not have one. Then navigate to &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Applications&lt;/strong&gt; → &lt;strong&gt;Add application&lt;/strong&gt; and select &lt;strong&gt;Machine to machine&lt;/strong&gt;.&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.amazonaws.com%2Fuploads%2Farticles%2Fnpep5iw6cko1ozjgob57.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.amazonaws.com%2Fuploads%2Farticles%2Fnpep5iw6cko1ozjgob57.png" alt="Kinde Settings &amp;gt; Applications page showing the " width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Give it a descriptive name like &lt;code&gt;My MCP Server Agent&lt;/code&gt;. Once created, open the application details and note down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client ID&lt;/strong&gt; — the agent's unique identity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client Secret&lt;/strong&gt; — the credential used to obtain tokens (treat this like a password)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token endpoint&lt;/strong&gt; — &lt;code&gt;https://YOUR_DOMAIN.kinde.com/oauth2/token&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWKS endpoint&lt;/strong&gt; — &lt;code&gt;https://YOUR_DOMAIN.kinde.com/.well-known/jwks.json&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now register your MCP server as an API in Kinde. Navigate to &lt;strong&gt;APIs&lt;/strong&gt; → &lt;strong&gt;Add API&lt;/strong&gt;. Give it a name (e.g. &lt;code&gt;My MCP Server&lt;/code&gt;) and an audience identifier (e.g. &lt;code&gt;https://mcp.yourapp.com&lt;/code&gt;). This audience value will be embedded in every token issued for your server — your server checks it on every request to ensure the token was meant for it specifically, preventing token passthrough attacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #2: Define Scopes for Tool-Level Permissions
&lt;/h2&gt;

&lt;p&gt;This is the step most MCP servers skip entirely, and it is the most important one.&lt;/p&gt;

&lt;p&gt;In your Kinde API definition (under &lt;strong&gt;APIs&lt;/strong&gt; → select your API → &lt;strong&gt;Scopes&lt;/strong&gt;), define one scope per logical group of tools. Do not define one global scope — that defeats the purpose. Think about the categories of capability your MCP server exposes and create a scope for each.&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.amazonaws.com%2Fuploads%2Farticles%2Fnepm7ysulx301gn4gsiw.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.amazonaws.com%2Fuploads%2Farticles%2Fnepm7ysulx301gn4gsiw.png" alt="Kinde API scopes page showing several scopes defined for the MCP server, e.g., tools:read, tools:write, admin:users, admin:orgs. Each scope has a name and description visible" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a hypothetical MCP server that manages a SaaS product's users and data, the scopes might look like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Tools it authorizes&lt;/th&gt;
&lt;th&gt;Who gets it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;users:read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get_user&lt;/code&gt;, &lt;code&gt;list_users&lt;/code&gt;, &lt;code&gt;search_users&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Read-only agents, analytics pipelines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;users:write&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;create_user&lt;/code&gt;, &lt;code&gt;update_user&lt;/code&gt;, &lt;code&gt;invite_user&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;CRM agents, onboarding automations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;users:delete&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;delete_user&lt;/code&gt;, &lt;code&gt;suspend_user&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Admin agents only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;data:read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;query_records&lt;/code&gt;, &lt;code&gt;export_data&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Reporting agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;data:write&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;create_record&lt;/code&gt;, &lt;code&gt;update_record&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Integration agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;admin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All tools&lt;/td&gt;
&lt;td&gt;Trusted internal agents only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now grant your M2M application the scopes it actually needs. Navigate to your M2M application → &lt;strong&gt;APIs&lt;/strong&gt; → select your API → check only the scopes this specific agent should have. An agent that only needs to read users gets &lt;code&gt;users:read&lt;/code&gt;. It cannot request a token with &lt;code&gt;users:delete&lt;/code&gt; even if it tries — Kinde will not issue it.&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.amazonaws.com%2Fuploads%2Farticles%2F4hmhlv5e9pu29y9jhoua.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.amazonaws.com%2Fuploads%2Farticles%2F4hmhlv5e9pu29y9jhoua.png" alt="Kinde M2M application details showing the API scopes section with some scopes checked (users:read, data:read) and others unchecked (users:delete, admin" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Terrific! The authorization policy is now defined. Time to build the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #3: Build the MCP Server with Token Validation
&lt;/h2&gt;

&lt;p&gt;Now write the MCP server itself. This is a Node.js server using the &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; package. The key additions over a basic MCP server are the JWT validation middleware and the per-tool scope enforcement.&lt;/p&gt;

&lt;p&gt;First, install the dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @modelcontextprotocol/sdk jwks-rsa jsonwebtoken express
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the server file:&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="c1"&gt;// server.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;McpServer&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="s2"&gt;@modelcontextprotocol/sdk/server/mcp.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StreamableHTTPServerTransport&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="s2"&gt;@modelcontextprotocol/sdk/server/streamableHttp.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextFunction&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="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jsonwebtoken&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;jwksClient&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jwks-rsa&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&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="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ─── Configuration ──────────────────────────────────────────────────────────&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KINDE_DOMAIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// e.g. "yourapp.kinde.com"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MCP_AUDIENCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MCP_AUDIENCE&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// e.g. "https://mcp.yourapp.com"&lt;/span&gt;

&lt;span class="c1"&gt;// JWKS client — fetches Kinde's public signing keys&lt;/span&gt;
&lt;span class="c1"&gt;// Used to verify that incoming tokens were genuinely signed by Kinde&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jwks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;jwksClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;jwksUri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.well-known/jwks.json`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cacheMaxEntries&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="na"&gt;cacheMaxAge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 10 minutes&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// ─── JWT Validation ──────────────────────────────────────────────────────────&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;TokenPayload&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;sub&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="c1"&gt;// agent identity (M2M app client ID)&lt;/span&gt;
  &lt;span class="nl"&gt;scp&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="c1"&gt;// space-separated scopes&lt;/span&gt;
  &lt;span class="nl"&gt;permissions&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="c1"&gt;// Kinde also surfaces permissions here&lt;/span&gt;
  &lt;span class="nl"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&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;iss&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;exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;TokenPayload&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Missing or malformed Authorization header&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Decode the header to find the key ID (kid)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid token format&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&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;kid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;kid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Token missing key ID (kid)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Fetch the public key from Kinde's JWKS endpoint&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signingKey&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;jwks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSigningKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kid&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;publicKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;signingKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPublicKey&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Verify the token — this checks signature, expiry, issuer, and audience&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RS256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MCP_AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// prevents token passthrough attacks&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;TokenPayload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ─── Scope Enforcement ───────────────────────────────────────────────────────&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requireScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requiredScope&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &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="nx"&gt;TokenPayload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Kinde puts scopes in the `scp` claim as a space-separated string&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokenScopes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scp&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&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="c1"&gt;// Also check the `permissions` array (Kinde's permission system)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="o"&gt;??&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;hasScope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nx"&gt;tokenScopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requiredScope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;tokenScopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requiredScope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hasScope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`Insufficient permissions. Required: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;requiredScope&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s2"&gt;`Token has: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tokenScopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ─── Auth Middleware ──────────────────────────────────────────────────────────&lt;/span&gt;

&lt;span class="c1"&gt;// Store the validated token payload on the request object&lt;/span&gt;
&lt;span class="c1"&gt;// so tool handlers can access it without re-validating&lt;/span&gt;
&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="nb"&gt;global&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nx"&gt;Express&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;agentToken&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;TokenPayload&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextFunction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&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;payload&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;validateToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Agent authenticated: &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="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; with scopes: &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="nx"&gt;scp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Auth failed: &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unauthorized&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;message&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ─── MCP Server Definition ────────────────────────────────────────────────────&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;McpServer&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="s2"&gt;secure-mcp-server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Tool #1: get_user — requires users:read scope&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;get_user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Retrieve a user by their ID&lt;/span&gt;&lt;span class="dl"&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;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The unique identifier of the user to retrieve&lt;/span&gt;&lt;span class="dl"&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;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;_meta&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Access the validated token from the request context&lt;/span&gt;
    &lt;span class="c1"&gt;// The token was validated and attached by authMiddleware before this handler runs&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_meta&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;agentToken&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;TokenPayload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;requireScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Your actual business logic here&lt;/span&gt;
    &lt;span class="c1"&gt;// This is a placeholder — replace with real data fetching&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;content&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="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="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&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;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user@example.com&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="s2"&gt;Jane Smith&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2025-01-15T10:30:00Z&lt;/span&gt;&lt;span class="dl"&gt;"&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="p"&gt;],&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="c1"&gt;// Tool #2: create_user — requires users:write scope&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create_user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Create a new user account&lt;/span&gt;&lt;span class="dl"&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;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Email address for the new user&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="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Full name of the user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;member&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viewer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;member&lt;/span&gt;&lt;span class="dl"&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;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;_meta&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_meta&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;agentToken&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;TokenPayload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;requireScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users:write&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Log who created the user for audit purposes&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`User creation requested by agent: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Your actual user creation logic here&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;content&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="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="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&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;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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="s2"&gt;`usr_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;created_by_agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&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="p"&gt;],&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="c1"&gt;// Tool #3: delete_user — requires users:delete scope (most restrictive)&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delete_user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Permanently delete a user account&lt;/span&gt;&lt;span class="dl"&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;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ID of the user to delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Reason for deletion (required for audit trail)&lt;/span&gt;&lt;span class="dl"&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;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;_meta&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_meta&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;agentToken&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;TokenPayload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// This scope is only granted to trusted admin agents&lt;/span&gt;
    &lt;span class="c1"&gt;// Read-only agents cannot call this tool even if they know it exists&lt;/span&gt;
    &lt;span class="nf"&gt;requireScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users:delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`AUDIT: User &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; deleted by agent &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Reason: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;content&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="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="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&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;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;deleted_by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&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="p"&gt;],&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="c1"&gt;// ─── HTTP Server ──────────────────────────────────────────────────────────────&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// The auth middleware runs on every MCP request before the tool handlers&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/mcp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;transport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StreamableHTTPServerTransport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;sessionIdGenerator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Pass the validated token into the MCP request context&lt;/span&gt;
  &lt;span class="c1"&gt;// so tool handlers can access it via _meta.agentToken&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;agentToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Protected resource metadata endpoint — required for OAuth 2.1 discovery&lt;/span&gt;
&lt;span class="c1"&gt;// MCP clients fetch this to learn where to get tokens&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/.well-known/oauth-protected-resource&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MCP_AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;authorization_servers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;scopes_supported&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users:write&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users:delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data:write&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&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;bearer_methods_supported&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;header&lt;/span&gt;&lt;span class="dl"&gt;"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Secure MCP Server running on port &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Protected resource metadata: http://localhost:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.well-known/oauth-protected-resource`&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;&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.amazonaws.com%2Fuploads%2Farticles%2Fvw1yk6dwfxqlumsu0l9x.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.amazonaws.com%2Fuploads%2Farticles%2Fvw1yk6dwfxqlumsu0l9x.png" alt="Terminal showing the MCP server running, with log output like " width="800" height="269"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #4: How Agents Obtain Tokens
&lt;/h2&gt;

&lt;p&gt;Your MCP server is now a protected resource. To call it, an agent needs a token. Here is how an OpenClaw-style agent — or any MCP client — would obtain one using Kinde's client credentials flow:&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="c1"&gt;// agent-token.ts&lt;/span&gt;
&lt;span class="c1"&gt;// This code runs in the AGENT (the MCP client), not in the MCP server&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAgentToken&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokenResponse&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/oauth2/token`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;grant_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;client_credentials&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_M2M_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;client_secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_M2M_CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MCP_AUDIENCE&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// your MCP server's audience&lt;/span&gt;
        &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users:read data:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// only request what this agent needs&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tokenResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&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;error&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;tokenResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Token request failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;access_token&lt;/span&gt; &lt;span class="p"&gt;}&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;tokenResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Then use the token in every MCP request&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;callMcpTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&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;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;)&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;token&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;getAgentToken&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;response&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://mcp.yourapp.com/mcp&lt;/span&gt;&lt;span class="dl"&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Kinde-issued JWT&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;jsonrpc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tools/call&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&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="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;arguments&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&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;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;Note: Tokens issued via client credentials are valid for one hour by default in Kinde. Agents should cache the token and only request a new one when it is close to expiry — not on every tool call. Check the &lt;code&gt;expires_in&lt;/code&gt; field in the token response and refresh when there are 60 seconds or fewer remaining.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #5: Configure an OpenClaw Skill With Proper Auth
&lt;/h2&gt;

&lt;p&gt;To connect a properly authenticated MCP server to an OpenClaw agent, you configure the skill in &lt;code&gt;openclaw.json&lt;/code&gt; with the token endpoint details. Here is what a secure skill configuration looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"entries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"my-secure-tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"transport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"streamable-http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.yourapp.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oauth2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"flow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"client_credentials"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://YOUR_DOMAIN.kinde.com/oauth2/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"client_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your_m2m_client_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"client_secret_env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MY_TOOL_CLIENT_SECRET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"audience"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.yourapp.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"users:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"data:read"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: &lt;code&gt;client_secret_env&lt;/code&gt; points to an environment variable rather than embedding the secret inline — critical for anyone publishing skill configurations or committing them to version control. The secret itself lives in the environment, not in the config file.&lt;/p&gt;

&lt;p&gt;This configuration gives your agent the specific scopes it needs and nothing more. If someone performs a prompt injection attack via Moltbook and tries to get this agent to call &lt;code&gt;delete_user&lt;/code&gt;, the token does not have the &lt;code&gt;users:delete&lt;/code&gt; scope — the MCP server rejects the call before it can execute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It All Together
&lt;/h2&gt;

&lt;p&gt;Here is the complete security architecture you have built:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────┐
│                         Kinde                                   │
│                                                                 │
│  M2M Application (Agent Identity)                               │
│  ├── Client ID: m2m_abc123                                      │
│  ├── Granted scopes: users:read, data:read                      │
│  └── NOT granted: users:write, users:delete, admin              │
│                                                                 │
│  JWKS Endpoint: /well-known/jwks.json (public key verification) │
│  Token Endpoint: /oauth2/token (client credentials flow)        │
└───────────────────────────────┬─────────────────────────────────┘
                                │ issues signed JWT (1hr TTL)
                                │ with scopes: users:read data:read
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Agent (OpenClaw)                           │
│                                                                 │
│  Holds JWT in memory (never stored on disk)                     │
│  Sends: Authorization: Bearer &amp;lt;JWT&amp;gt; on every MCP request        │
└───────────────────────────────┬─────────────────────────────────┘
                                │ HTTPS POST /mcp
                                │ Authorization: Bearer &amp;lt;JWT&amp;gt;
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Secure MCP Server                            │
│                                                                 │
│  1. authMiddleware: validates JWT signature via JWKS            │
│     → checks issuer, audience, expiry                           │
│                                                                 │
│  2. Per-tool scope check: requireScope("users:read")            │
│     → tools/get_user ✓ (scope present)                          │
│     → tools/delete_user ✗ (scope missing → 401)                 │
│                                                                 │
│  3. Audit log: agent ID, tool called, timestamp                 │
└─────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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.amazonaws.com%2Fuploads%2Farticles%2Fus3n8mdjusy3hbcep5xw.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.amazonaws.com%2Fuploads%2Farticles%2Fus3n8mdjusy3hbcep5xw.png" alt="Clean visual version of the ASCII architecture above — showing Kinde (auth layer) on the left, Agent (OpenClaw) in the middle, Secure MCP Server on the right. Arrows show the token flow. The scope check at the tool boundary is highlighted." width="800" height="274"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The security properties you now have that OpenClaw's default Skills did not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Insecure MCP (OpenClaw default)&lt;/th&gt;
&lt;th&gt;Secure MCP (with Kinde)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Agent identity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Anonymous / shared API key&lt;/td&gt;
&lt;td&gt;Cryptographic JWT (&lt;code&gt;sub&lt;/code&gt; claim = M2M app ID)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Token forgability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;API key can be shared/leaked&lt;/td&gt;
&lt;td&gt;RS256 JWT signed by Kinde — cannot be forged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tool-level permissions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;All tools accessible to all callers&lt;/td&gt;
&lt;td&gt;Per-tool scope enforcement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Prompt injection blast radius&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Agent can call any tool if injected&lt;/td&gt;
&lt;td&gt;Injected instruction can only call scoped tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Token expiry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;API keys never expire&lt;/td&gt;
&lt;td&gt;1-hour TTL — breach window is bounded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Revocation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Must rotate key for all agents&lt;/td&gt;
&lt;td&gt;Revoke one M2M app, others unaffected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Audit trail&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Agent ID + scope logged on every tool call&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Testing Your Security Layer
&lt;/h2&gt;

&lt;p&gt;Before deploying, verify these four scenarios:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 1 — Valid token, allowed scope.&lt;/strong&gt; Call &lt;code&gt;get_user&lt;/code&gt; with a token that has &lt;code&gt;users:read&lt;/code&gt;. You should receive the user data. This confirms the happy path works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 2 — Valid token, insufficient scope.&lt;/strong&gt; Call &lt;code&gt;delete_user&lt;/code&gt; with a token that only has &lt;code&gt;users:read&lt;/code&gt;. You should receive a 401 with a message like "Insufficient permissions. Required: users:delete." This confirms scope enforcement works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 3 — Expired token.&lt;/strong&gt; Manually modify the &lt;code&gt;exp&lt;/code&gt; claim of a token or wait for it to expire, then attempt a call. You should receive a 401 with a JWT expiry error. This confirms token lifecycle enforcement works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 4 — Wrong audience.&lt;/strong&gt; Use a token issued for a different API (different &lt;code&gt;aud&lt;/code&gt; claim) and attempt a call to your MCP server. You should receive a 401 with an audience mismatch error. This confirms that token passthrough attacks are blocked.&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;# Test 2 example — calling a tool without the required scope&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://your-mcp-server.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$READ_ONLY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "delete_user",
      "arguments": {
        "user_id": "usr_123",
        "reason": "Test deletion attempt"
      }
    },
    "id": 1
  }'&lt;/span&gt;

&lt;span class="c"&gt;# Expected response:&lt;/span&gt;
&lt;span class="c"&gt;# HTTP 401 Unauthorized&lt;/span&gt;
&lt;span class="c"&gt;# { "error": "unauthorized", "message": "Insufficient permissions. Required: users:delete. Token has: users:read" }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Amazing! Your MCP server is now hardened against the attack patterns that took down OpenClaw deployments and turned Moltbook into a security incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In this article, you built a secure MCP server with scoped, tool-level authentication — the approach that the OpenClaw and Moltbook incidents showed was critically missing from most agentic AI deployments. Your server now knows who every connecting agent is, what they are allowed to do, and rejects anything outside those boundaries at the tool boundary before execution.&lt;/p&gt;

&lt;p&gt;The key insight from Kinde's own MCP server — shipped in January 2026 as a working reference implementation — is that security for AI agents is not fundamentally different from security for any other kind of software. M2M identities, scoped tokens, short TTLs, and JWT validation are not new concepts. What is new is applying them consistently to MCP servers, which the ecosystem has been slow to do.&lt;/p&gt;

&lt;p&gt;Agentic AI is not going away. OpenClaw proved that. The question is whether the infrastructure it runs on is built securely. That starts with the MCP server you control.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=9&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde is free&lt;/a&gt; for up to 10,500 monthly active users, no credit card required. Create your account at &lt;a href="https://kinde.com" rel="noopener noreferrer"&gt;kinde.com&lt;/a&gt; and start treating your agents as the production infrastructure they are.&lt;/p&gt;

</description>
      <category>kinde</category>
      <category>ai</category>
      <category>mcp</category>
      <category>agents</category>
    </item>
    <item>
      <title>Building a Live AI Market Research Terminal: How Bright Data and Convex Replace Polling With Real-Time Everything</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Tue, 10 Mar 2026 08:14:08 +0000</pubDate>
      <link>https://dev.to/sholajegede/building-a-live-ai-market-research-terminal-how-bright-data-and-convex-replace-polling-with-mm</link>
      <guid>https://dev.to/sholajegede/building-a-live-ai-market-research-terminal-how-bright-data-and-convex-replace-polling-with-mm</guid>
      <description>&lt;p&gt;Most AI tools fall apart at the same point: making sure the output is grounded in what's happening right now, not what the model was trained on months ago.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://brightdata.com" rel="noopener noreferrer"&gt;Bright Data&lt;/a&gt; built an open-source demo that solves this. It's called the &lt;a href="https://demos.brightdata.com/market-terminal" rel="noopener noreferrer"&gt;Signal Terminal&lt;/a&gt;, a financial research tool built around that problem.&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.amazonaws.com%2Fuploads%2Farticles%2Fzt9ly527zi2yad16kq3b.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.amazonaws.com%2Fuploads%2Farticles%2Fzt9ly527zi2yad16kq3b.png" alt="The Signal Terminal homepage. Showing a clean search interface with the " width="800" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Tool Exists
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/in/hello-agents" rel="noopener noreferrer"&gt;Meir Kadosh&lt;/a&gt; is an AI Engineer at Bright Data, and he kept noticing the same thing happening with financial customers. They would pull Google search results, pass them to an LLM, scrape the relevant pages for deeper context, and try to piece together why a stock had moved. The pipeline was almost identical every time. Different teams, same use case, everyone rebuilding from scratch.&lt;/p&gt;

&lt;p&gt;So Meir built the canonical version — an open-source reference architecture that financial customers can use directly and developers can build on.&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.amazonaws.com%2Fuploads%2Farticles%2Fx5m5o5buz9bi0szrhr9j.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.amazonaws.com%2Fuploads%2Farticles%2Fx5m5o5buz9bi0szrhr9j.png" alt="An image of a completed research run. Shows the full dashboard with Breaking Tape, Evidence Map, and Sources panel visible" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens When You Ask a Question
&lt;/h2&gt;

&lt;p&gt;Type &lt;em&gt;"Why did Nvidia's stock drop today?"&lt;/em&gt; and the app runs a structured pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plan → search → scrape → extract → link → cluster → render
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each stage is visible and each source is attributed.&lt;/p&gt;

&lt;p&gt;The planning stage comes first. The model generates specific search angles based on the question: what to look for, how recent, which categories of signals matter, things like macro events, earnings surprises, analyst actions, and news catalysts.&lt;/p&gt;

&lt;p&gt;Then Bright Data's SERP API fires across Google and returns structured JSON for the most relevant sources in under a second. For pages that need deeper analysis, Bright Data's Web Unlocker fetches the full content as clean Markdown, handling the sites that block standard scrapers.&lt;/p&gt;

&lt;p&gt;From there the LLM pulls named entities, causal relationships, and key figures from the content, those entities get connected into an evidence graph, related signals cluster into narrative themes, and everything surfaces in a live multi-panel dashboard.&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.amazonaws.com%2Fuploads%2Farticles%2Fv6y5mps9jvlrhtbpk37q.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.amazonaws.com%2Fuploads%2Farticles%2Fv6y5mps9jvlrhtbpk37q.png" alt="An image of the evidence map in graph mode. Shows nodes and edges connecting entities, sources, and narratives" width="800" height="491"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The evidence map shows exactly which sources contributed which claims, how they connect to each other, and which narratives the evidence actually supports.&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.amazonaws.com%2Fuploads%2Farticles%2Fvs43p568uc7p4ww9veoi.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.amazonaws.com%2Fuploads%2Farticles%2Fvs43p568uc7p4ww9veoi.png" alt="An image of the evidence map in flow mode." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Paths, Depending on What You Need
&lt;/h2&gt;

&lt;p&gt;Not every query needs the same depth.&lt;/p&gt;

&lt;p&gt;The fast path uses SERP results only. Bright Data's SERP API returns structured JSON for each result (URL, description, position) and that passes directly to the LLM. Standard SERP delivers in 1-2 seconds, and with Bright Data's premium infrastructure, complete results come back in under one second. For real-time chat interfaces where latency is the whole point, this is the right path.&lt;/p&gt;

&lt;p&gt;The deep path, which Meir calls &lt;strong&gt;"search and extract"&lt;/strong&gt; internally, adds a second layer. After SERP identifies the relevant sources, Web Unlocker fetches each page's full content as clean Markdown, which then goes to the LLM for extraction and analysis. It takes longer, but for a full research run where completeness matters more than speed, that tradeoff makes sense.&lt;/p&gt;

&lt;p&gt;The SERP route maps the incoming format param to Bright Data's format string and fires the request:&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="c1"&gt;// app/api/serp/route.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&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;brightDataSerpGoogle&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;format&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;full&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;     &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;full_json_google&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;  &lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;format&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;markdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;markdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;          &lt;span class="p"&gt;:&lt;/span&gt;
                            &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light_json_google&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;vertical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 'web' | 'news'&lt;/span&gt;
  &lt;span class="nx"&gt;recency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 'h' | 'd' | 'w' | 'm' | 'y'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token is &lt;code&gt;BRIGHTDATA_API_TOKEN&lt;/code&gt; (with &lt;code&gt;API_TOKEN&lt;/code&gt; as fallback). The SERP zone resolves through &lt;code&gt;BRIGHTDATA_SERP_ZONE&lt;/code&gt; then &lt;code&gt;BRIGHTDATA_SERP_ZONE_NAME&lt;/code&gt;, falling back to the Web Unlocker zone if neither is set. The Web Unlocker zone has its own resolution chain, defaulting to &lt;code&gt;mcp_unlocker&lt;/code&gt; if nothing is configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Bright Data and Not Something Else
&lt;/h2&gt;

&lt;p&gt;If you've built a scraper before, you already know how this story goes. It works locally. You deploy it. It gets blocked. You add proxy rotation and now some sites work, but LinkedIn doesn't, and Reddit doesn't, and when you look up how to handle TLS fingerprinting you're suddenly two weeks deep into a rabbit hole that has nothing to do with your actual product.&lt;/p&gt;

&lt;p&gt;Getting clean data from LinkedIn, Reddit, X, and TikTok means solving years of adversarial engineering: TLS fingerprinting, browser fingerprinting, behavioral analysis, rotating CAPTCHAs. Web Unlocker handles all of that. The platforms that reliably block everything else.&lt;/p&gt;

&lt;p&gt;Coverage spans seven major search engines and 195 countries with city-level targeting, so the results reflect what someone in a specific location actually sees.&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.amazonaws.com%2Fuploads%2Farticles%2F01kqwkjb8fpnoasucy7q.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.amazonaws.com%2Fuploads%2Farticles%2F01kqwkjb8fpnoasucy7q.png" alt="An image showing Bright Data's SERP API documentation showing the sub-second response time for premium infrastructure" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part Most Developers Skip
&lt;/h2&gt;

&lt;p&gt;There's a failure mode that shows up constantly in LLM pipelines built on web data. You fetch a full page, pass it to the model, and the output is worse than you expected. The instinct is to blame the model. Usually the model is fine. The context is the problem.&lt;/p&gt;

&lt;p&gt;A full webpage is mostly noise. Navigation menus, cookie banners, author bios, related articles, advertisement slots. All of that lands in the context window alongside the three paragraphs that actually matter to your query. Flooding the model with irrelevant tokens makes the output worse, drives up token costs, and gives the model more surface area to hallucinate from.&lt;/p&gt;

&lt;p&gt;The Signal Terminal handles this with a reflection stage that runs between fetching and analysing. Before the main analysis call, an extraction step strips everything irrelevant from the Markdown. Only the facts, named entities, causal statements, and figures directly related to the original query reach the analysis model.&lt;/p&gt;

&lt;p&gt;Each stage has a dedicated prompt-builder function. The summarization stage looks like this:&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="c1"&gt;// lib/prompts/signal-terminal.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildSignalTerminalSummariesPrompt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;evidenceExcerpts&lt;/span&gt; &lt;span class="p"&gt;})&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;system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are a market news summarizer for active traders.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Use only the provided excerpts; do not invent facts.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;If an excerpt is low-signal (navigation/menus/price page), omit it.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Return strict JSON only.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&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;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;`Topic: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Evidence excerpts (JSON):&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evidenceExcerpts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Return JSON: { items: [{ id, bullets: string[2..5], entities?, catalysts?, sentiment?, confidence? }] }&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;- Write for traders: prefer concrete nouns, numbers, and named actors.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;- Sentiment: "bullish" | "bearish" | "mixed" | "neutral" — reflect the excerpt, not your opinion.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;- Confidence: 0..1 based on excerpt specificity (dates/numbers/attribution increases confidence).&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&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;Every stage has its own function like this: planning, impact graph expansion, chat. The model never gets a freeform prompt and a wall of raw text. It gets a tightly scoped system instruction, a JSON payload of curated evidence, and a strict output schema. The analysis model cannot fabricate beyond what it's been given.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Convex Comes In
&lt;/h2&gt;

&lt;p&gt;Once Bright Data has acquired the evidence, it needs somewhere to go that can stream each pipeline stage to the UI as it completes, persist the results for follow-up queries, and stay fast enough that the interface actually feels live.&lt;/p&gt;

&lt;p&gt;Meir’s first choice for this layer was Supabase. He switched to Convex because the latency difference in the real-time chat experience was noticeable enough to change the stack.&lt;/p&gt;

&lt;p&gt;Relational databases have no built-in reactivity. If you want to know when something changes, you build it yourself — polling, pub/sub servers, WebSocket management layered on top of your existing stack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.convex.dev" rel="noopener noreferrer"&gt;Convex&lt;/a&gt; tracks the data dependencies of every active query, and the moment any of those dependencies change, it reruns the affected query and pushes the updated result to every subscribed client over a persistent WebSocket. For a pipeline that executes in sequential stages, you want the frontend to reflect each stage as it lands, not on the next poll cycle.&lt;/p&gt;

&lt;p&gt;Beyond reactivity, Convex brought two things that would have required separate infrastructure elsewhere: full-text search built into the database, and a transactional scheduler for TTL cleanup. No cron jobs, no external queue, no search service.&lt;/p&gt;

&lt;p&gt;Convex ends up serving three distinct roles in the Signal Terminal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Role 1: Streaming Pipeline Events in Real Time
&lt;/h3&gt;

&lt;p&gt;Each stage of the pipeline writes its results to Convex the moment it completes, and the frontend subscribes with a single &lt;code&gt;useQuery&lt;/code&gt; hook.&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="c1"&gt;// convex/schema.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineSchema&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;defineTable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&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="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;by_sessionId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sessionId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;searchIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;search_topic&lt;/span&gt;&lt;span class="dl"&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;searchField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;topic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;

  &lt;span class="na"&gt;sessionEvents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;defineTable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;by_sessionId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sessionId&lt;/span&gt;&lt;span class="dl"&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 server writes an event every time a stage completes: &lt;code&gt;search.partial&lt;/code&gt;, &lt;code&gt;evidence&lt;/code&gt;, &lt;code&gt;graph&lt;/code&gt;, &lt;code&gt;clusters&lt;/code&gt;. The client sees it the moment it lands. No polling interval to tune, no missed transitions.&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.amazonaws.com%2Fuploads%2Farticles%2F20p27iqw426yyeuambw8.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.amazonaws.com%2Fuploads%2Farticles%2F20p27iqw426yyeuambw8.png" alt="An image of the Breaking Tape panel updating in real time as each pipeline stage completes" width="800" height="249"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Role 2: Topic-Indexed Search for Follow-Up Chat (RAG Without Vectors)
&lt;/h3&gt;

&lt;p&gt;After a research run completes, users can ask follow-up questions like &lt;em&gt;"What did the Reuters article say about supply chain risk?"&lt;/em&gt; The app retrieves relevant past evidence without re-running the full Bright Data pipeline.&lt;/p&gt;

&lt;p&gt;Convex handles this by indexing the &lt;code&gt;topic&lt;/code&gt; field and enabling text-based search across historical sessions. No embeddings, no vector database, no additional infrastructure.&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="c1"&gt;// convex/sessions.ts — search past sessions by topic keyword&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Full-text search across the topic field via searchIndex&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;results&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;ctx&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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sessions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withSearchIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;search_topic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;topic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;results&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;ctx&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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sessions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;results&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No separate index on &lt;code&gt;topic&lt;/code&gt;. Convex's &lt;code&gt;searchIndex&lt;/code&gt; handles full-text retrieval.&lt;/p&gt;

&lt;p&gt;The same mechanism that powers the follow-up chat also powers session history browsing. And because this is a Convex query, it's reactive: if a new session for the same topic completes while the user is in chat, the context updates automatically.&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.amazonaws.com%2Fuploads%2Farticles%2Ftc2u8i932owr83bc5dq7.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.amazonaws.com%2Fuploads%2Farticles%2Ftc2u8i932owr83bc5dq7.png" alt="An image of the chat interface showing a follow-up question being answered. Demonstrates that the conversation references the evidence from the research run" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Role 3: Scheduled Cleanup
&lt;/h3&gt;

&lt;p&gt;Convex's built-in scheduler handles cleanup without a cron job. When a session is created, a cleanup task is scheduled to run exactly 24 hours later.&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="c1"&gt;// convex/sessions.ts — TTL scheduled at creation time&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TTL_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;create&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&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="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sessions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Schedule cleanup for exactly this session, 24h from now&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TTL_MS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deleteExpired&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionId&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="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deleteExpired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;internalMutation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;session&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;ctx&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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sessions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;by_sessionId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sessionId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&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;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&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;events&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;ctx&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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sessionEvents&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;by_sessionId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sessionId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for &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;ev&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&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;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each session cleans up after itself. The scheduler fires once per session, 24 hours after creation, and deletes that session and all its events. Because scheduling from a mutation is atomic, if the session creation succeeds, the cleanup is guaranteed to be scheduled, and because it's a mutation, it's guaranteed to execute exactly once.&lt;/p&gt;

&lt;h2&gt;
  
  
  On the Model Choice
&lt;/h2&gt;

&lt;p&gt;The app runs on Gemini 3.0 Flash via OpenRouter. The architecture supports full model configurability: different models per stage, per speed mode. Each stage (planning, summaries, artifacts, chat) can be independently configured with a base model, a fast variant, and a deep variant.&lt;/p&gt;

&lt;p&gt;At runtime, the selection logic works down a priority chain: explicit caller override first, then the stage-specific fast or deep model, then the stage base, then the provider default.&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="c1"&gt;// lib/ai/model-selector.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;selectStageModel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestedModel&lt;/span&gt; &lt;span class="p"&gt;})&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;explicit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;firstNonEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestedModel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;explicit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;explicit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// caller override wins&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openrouter&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;stageProfile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stageModels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// plan | summaries | artifacts | chat&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modeFallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fast&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelFast&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelDeep&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fast&lt;/span&gt;&lt;span class="dl"&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;return&lt;/span&gt; &lt;span class="nf"&gt;firstNonEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stageProfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stageProfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;modeFallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;firstNonEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stageProfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stageProfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;modeFallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&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;This matters in multi-stage pipelines because latency compounds. Planning, summarization, graph expansion, and chat all run in sequence, and a slow model at each step multiplies into a slow tool. A fast model at planning and summarization with a deeper model only for chat is a meaningful architectural choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Bright Data and Convex Have in Common
&lt;/h2&gt;

&lt;p&gt;Both solve the same class of problem in their respective layers. You could build what either one does yourself: proxy rotation and bot evasion on the data side, WebSocket management and pub/sub infrastructure on the persistence side.&lt;/p&gt;

&lt;p&gt;Both are years of engineering work that exist so you don't have to do them. Bright Data delivers structured, fresh web data at the moment you ask for it. Convex delivers that data to every subscribed client the moment it lands in the database. The application code gets to be about the product, not the infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture You Can Take With You
&lt;/h2&gt;

&lt;p&gt;The Signal Terminal is a financial research tool, but the pattern applies anywhere you need an AI that reasons about what's happening now: competitive intelligence, news monitoring, lead research, price tracking, sentiment analysis, supply chain surveillance.&lt;/p&gt;

&lt;p&gt;Bright Data handles the outside world — fresh, compliant web data at scale, with unlocking across platforms that block everything else. Convex handles the inside world: reactive persistence, streaming to the UI, querying without a vector database, housekeeping on a schedule. The LLM reasons only from what it's been given, because the reflection stage ensures it only receives what's relevant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It, Fork It, Build With It
&lt;/h2&gt;

&lt;p&gt;Go to &lt;a href="https://demos.brightdata.com/market-terminal" rel="noopener noreferrer"&gt;demos.brightdata.com/market-terminal&lt;/a&gt; and ask it a real question about a company or market event you already know something about. Watch each pipeline stage complete. See the evidence map build. Ask a follow-up in the chat and see whether the output matches what you already know.&lt;/p&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/brightdata/market-terminal" rel="noopener noreferrer"&gt;github.com/brightdata/market-terminal&lt;/a&gt;. Open source, contributions welcome.&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.amazonaws.com%2Fuploads%2Farticles%2F8bq6plv6gnb98fsjm1pd.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.amazonaws.com%2Fuploads%2Farticles%2F8bq6plv6gnb98fsjm1pd.png" alt="An image of the GitHub repository page" width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://discord.com/invite/convex" rel="noopener noreferrer"&gt;Convex community Discord&lt;/a&gt; is a strong resource for questions about the reactive database layer.&lt;/p&gt;

&lt;p&gt;Thanks to Meir Kadosh at Bright Data for walking me through all of this and building something worth writing about.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>brightdata</category>
      <category>convex</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AI Generated 47% of My Code Last Month. Here's What I Actually Had to Fix (And Why I Didn't Trust It With Auth)</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Tue, 27 Jan 2026 22:32:54 +0000</pubDate>
      <link>https://dev.to/sholajegede/ai-generated-47-of-my-code-last-month-heres-what-i-actually-had-to-fix-226h</link>
      <guid>https://dev.to/sholajegede/ai-generated-47-of-my-code-last-month-heres-what-i-actually-had-to-fix-226h</guid>
      <description>&lt;p&gt;It was 3:47 AM when my phone lit up. "Error: Maximum call stack size exceeded." My SaaS app was down. Users were waking up to broken dashboards, and I was staring at a stack trace pointing to code I barely recognized.&lt;/p&gt;

&lt;p&gt;Two weeks earlier, I'd been bragging about shipping features 3x faster with v0.app and GitHub Copilot. I'd generated entire components with a single prompt, scaffolded API routes in seconds, built what looked like a production-ready application in a fraction of the usual time. Now, at nearly 4 AM, I was debugging code I hadn't actually written.&lt;/p&gt;

&lt;p&gt;The authentication middleware was calling itself recursively under specific edge cases. The AI had generated elegant-looking code that handled 99% of scenarios perfectly. It just created an infinite loop for that remaining 1%.&lt;/p&gt;

&lt;p&gt;When I finally resolved the issue and analyzed my codebase, the numbers shocked me: &lt;strong&gt;47% of my code was AI-generated&lt;/strong&gt;. Not suggested and then heavily modified—actually written by AI tools with minimal changes from me.&lt;/p&gt;

&lt;p&gt;This isn't just my story. 41% of all code is now AI-generated, with 76% of developers using or planning to use AI coding tools. But nobody talks about what happens after the code is generated, especially when it comes to security.&lt;/p&gt;

&lt;p&gt;Over the past month, I catalogued every bug, every fix, every security hole. What I found falls into five distinct categories, and one critical decision: &lt;strong&gt;I chose &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=2&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; for authentication because I refused to let AI touch my security layer&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tools and The Numbers
&lt;/h2&gt;

&lt;p&gt;I used v0.app for frontend work and GitHub Copilot for everything else. Both are genuinely impressive. v0's composite pipeline catches errors in real-time. Copilot's autocomplete is exceptionally good at common patterns.&lt;/p&gt;

&lt;p&gt;My final codebase: 8,500 lines of code. 4,000 lines came primarily from AI tools. But the distribution reveals what AI is actually good at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Frontend Components: 65% AI-generated (v0.app)&lt;/li&gt;
&lt;li&gt;API Routes: 40% AI-generated (Copilot)
&lt;/li&gt;
&lt;li&gt;Database Schema: 30% AI-generated (Copilot)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auth Integration: 20% AI-generated (I wrote the rest myself with Kinde)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Utility Functions: 55% AI-generated (Copilot)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI excelled at presentational code and struggled with business logic. The closer code got to security, data integrity, or domain-specific rules, the less I trusted AI—and the more dangerous it was to let it write anything unsupervised.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5 Categories of Bugs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Hallucinations: Code That References Things That Don't Exist
&lt;/h3&gt;

&lt;p&gt;Early in development, Copilot suggested this for user registration:&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;validateEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sanitizeInput&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;@utils/email-validator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;checkPasswordStrength&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;@security/password-utils&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateRegistrationForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;email&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;password&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;name&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="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;validateEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid email address&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&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;sanitized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sanitizeInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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;passwordCheck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;checkPasswordStrength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;passwordCheck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Password too weak&lt;/span&gt;&lt;span class="dl"&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&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="nx"&gt;sanitized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&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;Professional variable names. Clear logic. Proper error handling. One problem: &lt;strong&gt;none of those imported functions existed&lt;/strong&gt;. Not in my project, not in npm, not anywhere.&lt;/p&gt;

&lt;p&gt;The research is alarming: a study of 576,000 code samples found 440,445 hallucinated packages. Nearly half a million references to things that don't exist. Worse, malicious actors can create packages with commonly hallucinated names containing malware.&lt;/p&gt;

&lt;p&gt;Copilot also suggested Convex had an &lt;code&gt;update&lt;/code&gt; method (it's &lt;code&gt;patch&lt;/code&gt;) and a &lt;code&gt;db.notifyUser&lt;/code&gt; method (complete fiction).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to spot hallucinations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IDE warnings are your friend. Investigate every red squiggly line.&lt;/li&gt;
&lt;li&gt;If it sounds too convenient, verify it exists.&lt;/li&gt;
&lt;li&gt;Before &lt;code&gt;npm install&lt;/code&gt;, check the package registry.&lt;/li&gt;
&lt;li&gt;Framework-specific methods need double-checking against docs.&lt;/li&gt;
&lt;li&gt;Use TypeScript strict mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Context Blindness: Ignoring Your Architecture
&lt;/h3&gt;

&lt;p&gt;My app used Convex for real-time data synchronization. When I asked v0 to create a task list component, it gave me classic REST-style React with &lt;code&gt;useState&lt;/code&gt;, &lt;code&gt;useEffect&lt;/code&gt;, manual fetching, and polling for updates every 5 seconds.&lt;/p&gt;

&lt;p&gt;But we're using Convex. Here's what it should have been:&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useQuery&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="s2"&gt;convex/react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;api&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="s2"&gt;@/convex/_generated/api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useKindeBrowserClient&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="s2"&gt;@kinde-oss/kinde-auth-nextjs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TaskList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useKindeBrowserClient&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;tasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getUserTasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;skip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Loading&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;No&lt;/span&gt; &lt;span class="nx"&gt;tasks&lt;/span&gt; &lt;span class="nx"&gt;yet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;space-y-4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TaskCard&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="p"&gt;))}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&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;No &lt;code&gt;useState&lt;/code&gt;, no &lt;code&gt;useEffect&lt;/code&gt;, no manual fetching, no polling. Convex's &lt;code&gt;useQuery&lt;/code&gt; handles all of that. When data changes, the component automatically re-renders. Simpler, more reliable, less code.&lt;/p&gt;

&lt;p&gt;Copilot made similar mistakes with database queries, generating N+1 query problems. With 50 tasks, that's 51 database queries instead of 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to prevent context blindness:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provide explicit context in prompts&lt;/li&gt;
&lt;li&gt;Create a CONVENTIONS.md file documenting your patterns&lt;/li&gt;
&lt;li&gt;Use linters to enforce patterns&lt;/li&gt;
&lt;li&gt;Build reusable abstractions that enforce conventions&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. "Almost Right": Subtle Logic Flaws
&lt;/h3&gt;

&lt;p&gt;66% of developers say the biggest issue with AI tools is solutions that are almost right, but not quite. The code runs without errors. TypeScript is happy. But there's a logic flaw that only reveals itself under specific conditions.&lt;/p&gt;

&lt;p&gt;When I asked Copilot for pagination, it generated code that calculated offsets wrong—page 1 showed items 10-19, not 0-9. It also loaded ALL tasks into memory before slicing, meaning with 10,000 tasks, we're loading all 10,000 to return 10.&lt;/p&gt;

&lt;p&gt;Another example: Copilot generated date arithmetic for recurring tasks that worked perfectly for December 1st + 1 month, but &lt;strong&gt;January 31st + 1 month = March 3rd&lt;/strong&gt; because JavaScript's &lt;code&gt;setMonth()&lt;/code&gt; handles overflow by rolling into the next month.&lt;/p&gt;

&lt;p&gt;The scariest bug was silent data loss. Copilot generated code that replaced an entire preferences object instead of merging it. The mutation succeeded, user got a success message, everything seemed fine—until they opened the app and wondered why their language and timezone settings were gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to catch these:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Test edge cases explicitly (end-of-month, leap years, zero values, empty arrays)&lt;/li&gt;
&lt;li&gt;Use property-based testing&lt;/li&gt;
&lt;li&gt;Code review with skepticism&lt;/li&gt;
&lt;li&gt;Add assertions liberally&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Security Vulnerabilities: Why I Chose Kinde
&lt;/h3&gt;

&lt;p&gt;This is where I drew the line. 48% of AI-generated code contains security vulnerabilities. After finding my first missing auth check, I made a decision: &lt;strong&gt;authentication would be the ONE thing I wouldn't let AI touch&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's what Copilot generated for a task deletion endpoint:&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="c1"&gt;// Copilot's security hole&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DELETE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&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="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&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="kr"&gt;string&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// No authentication check!&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;convex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deleteTask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;taskId&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Failed&lt;/span&gt;&lt;span class="dl"&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code works. It deletes tasks. &lt;strong&gt;Anyone&lt;/strong&gt; can call this endpoint and delete &lt;strong&gt;any&lt;/strong&gt; task. No authentication required.&lt;/p&gt;

&lt;p&gt;I initially missed two similar endpoints with the same vulnerability. They were exposed for three days before I found them during a security audit. That's when I decided: no more AI-generated auth code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Chose Kinde (Not AI) for Authentication
&lt;/h2&gt;

&lt;p&gt;After finding security holes in AI-generated auth code, I made a critical decision: authentication would be handled by Kinde, a proven platform that I could trust—not by AI guessing at security patterns.&lt;/p&gt;

&lt;p&gt;The moment that sealed it? I asked Copilot to generate middleware for route protection. It gave me code that looked professional but had three subtle vulnerabilities: it cached authentication state incorrectly, didn't handle token refresh, and had a race condition during session validation. These aren't bugs you catch in testing. They're production nightmares waiting to happen.&lt;/p&gt;

&lt;p&gt;I chose Kinde because:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Pre-built security that AI can't hallucinate&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Authentication, authorization, session management, and MFA all handled by Kinde. No AI making up phantom security methods. No missing auth checks. No subtle vulnerabilities that only surface under load.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=2&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; provides everything you need out of the box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OAuth integration (Google, GitHub, Microsoft, etc.)&lt;/li&gt;
&lt;li&gt;Multi-factor authentication&lt;/li&gt;
&lt;li&gt;Session management with automatic token refresh&lt;/li&gt;
&lt;li&gt;Role-based access control&lt;/li&gt;
&lt;li&gt;Organization management for B2B apps&lt;/li&gt;
&lt;li&gt;Webhooks for user events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most importantly, Kinde's SDK methods actually exist and do what they claim. When AI suggested &lt;code&gt;getKindeServerSession().validateToken()&lt;/code&gt; (which doesn't exist), I could verify against Kinde's docs that the correct pattern is &lt;code&gt;isAuthenticated()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Verifiable documentation I could trust&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When AI generated incorrect Kinde code, I could verify against real docs. Every method exists. Every pattern works. Here's the correct pattern Kinde documents:&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="c1"&gt;// app/api/tasks/[id]/route.ts - Verified Kinde pattern&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getKindeServerSession&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="s2"&gt;@kinde-oss/kinde-auth-nextjs/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ConvexHttpClient&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="s2"&gt;convex/browser&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;api&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="s2"&gt;@/convex/_generated/api&lt;/span&gt;&lt;span class="dl"&gt;"&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;convex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConvexHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_CONVEX_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DELETE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&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="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&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="kr"&gt;string&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// CRITICAL: Kinde handles auth correctly&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isAuthenticated&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getKindeServerSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authentication required&lt;/span&gt;&lt;span class="dl"&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&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;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Pass user ID to Convex for ownership verification&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;convex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deleteTask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;taskId&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Delete error:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Failed to delete task&lt;/span&gt;&lt;span class="dl"&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the corresponding Convex mutation with authorization:&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="c1"&gt;// convex/tasks.ts - Defense in depth with Convex&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mutation&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="s2"&gt;./_generated/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;v&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="s2"&gt;convex/values&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deleteTask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tasks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Verify auth at Convex level too&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;identity&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserIdentity&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not authenticated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Verify user ID matches&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;User ID mismatch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Get task to verify ownership&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;task&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;ctx&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Task not found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// CRITICAL: Verify ownership&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not authorized to delete this task&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Only now is it safe to delete&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&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;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This implements defense in depth: authentication at the API route level, authorization in the Convex mutation, and ownership verification before deletion. All three layers are necessary—and all three would have been compromised if I'd let AI write them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Seamless integration with my stack&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Kinde works perfectly with Convex and Next.js. Setting up the integration took 15 minutes:&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="c1"&gt;// app/api/auth/[kindeAuth]/route.ts - Single file setup&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handleAuth&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="s2"&gt;@kinde-oss/kinde-auth-nextjs/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&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="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&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;endpoint&lt;/span&gt; &lt;span class="o"&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;kindeAuth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handleAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endpoint&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's it. One file handles login, logout, callbacks, and token refresh. Compare that to the 200+ lines of auth code Copilot generated that I had to debug.&lt;/p&gt;

&lt;p&gt;The client-side integration is equally clean:&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="c1"&gt;// app/tasks/page.tsx - Server-side with Kinde&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getKindeServerSession&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="s2"&gt;@kinde-oss/kinde-auth-nextjs/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redirect&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="s2"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TasksPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isAuthenticated&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getKindeServerSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/auth/login&lt;/span&gt;&lt;span class="dl"&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;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TaskListClient&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;For client components, Kinde provides a dedicated hook:&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="c1"&gt;// components/UserProfile.tsx - Client-side&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useKindeBrowserClient&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="s2"&gt;@kinde-oss/kinde-auth-nextjs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UserProfile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useKindeBrowserClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Loading&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;picture&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;given_name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/p&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&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;And the Convex integration pattern:&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="c1"&gt;// convex/tasks.ts - Server-side auth helper&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAuthUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&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;identity&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserIdentity&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not authenticated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getUserTasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;user&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;getAuthUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tasks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;by_user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;userId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Organizations and permissions built-in&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My app needed team collaboration features. Copilot generated a custom permission system with 300+ lines of code. It had bugs in role inheritance, missing permission checks in mutations, and no audit trail.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=2&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's organization features&lt;/a&gt; handled this in 10 lines:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getOrganization&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getKindeServerSession&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;user&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;getUser&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;org&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;getOrganization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Kinde handles organization membership automatically&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No organization&lt;/span&gt;&lt;span class="dl"&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&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="c1"&gt;// Create task within organization context&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;convex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;create&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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Would AI have generated this correctly? Based on the 48% vulnerability rate and the hallucinations I found, probably not. Kinde gave me production-grade auth I could trust, with patterns I could verify against documentation instead of hoping the AI got it right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The security incident that never happened:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two weeks after launch, I ran a security audit. Every auth check was in place. Every permission was verified. Every session was handled correctly. The reason? I didn't trust AI to write security code, and Kinde ensured I didn't have to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Other vulnerabilities I found in AI-generated code:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Missing input validation (no length checks, no sanitization)&lt;/li&gt;
&lt;li&gt;Information disclosure (error handlers exposing stack traces)&lt;/li&gt;
&lt;li&gt;IDOR vulnerabilities (trusting client-provided IDs without ownership checks)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Key takeaway:&lt;/strong&gt; Security vulnerabilities in AI-generated code are systemic, not accidental. Kinde solved this by giving me battle-tested authentication that I didn't have to second-guess at 3:47 AM.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Performance Killers: Code That Works But Performs Terribly
&lt;/h3&gt;

&lt;p&gt;AI models optimize for correctness, not efficiency. Copilot loves N+1 queries:&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="c1"&gt;// Copilot's N+1 disaster  &lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getProjectsWithTasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;projects&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;ctx&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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;by_user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;userId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// One query for EACH project's tasks&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;projectsWithTasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;tasks&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;ctx&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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tasks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projectId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tasks&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;return&lt;/span&gt; &lt;span class="nx"&gt;projectsWithTasks&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With 10 projects, this runs 11 database queries. With 100 projects, 101 queries. During development with 3 test projects, it ran in 50ms. With a realistic dataset of 50 projects, it took 2.5 seconds.&lt;/p&gt;

&lt;p&gt;The fix: batch everything into 2 queries and combine in memory using Convex's indexes and proper query patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hidden Costs
&lt;/h2&gt;

&lt;p&gt;Beyond bugs, there are costs that accumulate slowly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical Debt:&lt;/strong&gt; GitClear's 2025 report found duplicated code blocks increased eightfold. I found four different implementations of email validation scattered across my codebase, each slightly different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review Burden:&lt;/strong&gt; Pre-AI, I reviewed 200 lines in 20 minutes. With AI code, that same 200 lines took 40-50 minutes. I had to verify everything was real, check for security issues, assess performance, and ensure consistency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Learning Impediment:&lt;/strong&gt; When AI writes the code, you skip the learning process. Three months later, when I needed to modify AI-generated presence tracking code, I realized I didn't understand how it worked. I had working code but no mental model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Saved Time
&lt;/h2&gt;

&lt;p&gt;Despite all this, AI saved me massive amounts of time. I shipped in 70 hours what would have taken 120 manually—a 40% time savings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where AI excelled:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Boilerplate and scaffolding (80% time savings)&lt;/li&gt;
&lt;li&gt;Type definitions (90% time savings)&lt;/li&gt;
&lt;li&gt;Test scaffolding (70% time savings)&lt;/li&gt;
&lt;li&gt;API route ceremony (75% time savings)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where human expertise was essential:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Architecture decisions (100% human)&lt;/li&gt;
&lt;li&gt;Security implementation (90% human—Kinde handled the rest)&lt;/li&gt;
&lt;li&gt;Business logic (85% human)&lt;/li&gt;
&lt;li&gt;System integration (80% human)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Real Math
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Promise:&lt;/strong&gt; Generate code 3-5x faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Reality:&lt;/strong&gt; Generate 3-5x faster, but spend 2-3x longer reviewing and fixing. Net result: 30-40% faster overall.&lt;/p&gt;

&lt;p&gt;That's still significant—but it's honest math accounting for review time, debugging, refactoring, and the decision to use Kinde instead of trusting AI with authentication.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five Lessons That Matter Most
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AI is a tool for iteration, not generation.&lt;/strong&gt; Use it for starting points, then iterate with human judgment.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat every AI suggestion as a hypothesis.&lt;/strong&gt; Verify everything—especially authentication, authorization, and business logic.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Security cannot be delegated to AI.&lt;/strong&gt; Use proven platforms like Kinde. Every security decision requires human review.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Strong engineering practices are your safety net.&lt;/strong&gt; TypeScript strict mode, linting, testing, and code review are essential with AI code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use AI strategically, not universally.&lt;/strong&gt; Let AI handle boilerplate. Keep architecture, business logic, and security in human hands.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Path Forward
&lt;/h2&gt;

&lt;p&gt;256 billion lines of AI-generated code have been written as of 2024. The models will improve. But fundamentally, AI pattern-matches against massive datasets. It doesn't understand your business requirements, architectural constraints, or security needs.&lt;/p&gt;

&lt;p&gt;The developers who thrive won't be the ones generating the most code. They'll be the ones who know exactly what to delegate to AI and what requires human expertise—or proven platforms like Kinde.&lt;/p&gt;

&lt;p&gt;AI generated 47% of my code last month. It helped me ship faster than I could have alone. But every bug I fixed, every security hole I patched with Kinde, every performance optimization I made—those taught me more about software engineering than any AI-generated code could have.&lt;/p&gt;

&lt;p&gt;The future isn't "AI writes all the code." It's AI and humans collaborating, each doing what they do best. AI generates patterns. Humans provide judgment. AI creates volume. Humans ensure quality. And platforms like Kinde handle security so you don't have to trust AI with the parts that matter most.&lt;/p&gt;

&lt;p&gt;Use AI. But use it wisely. Choose Kinde for auth. Review carefully. Test thoroughly. Ship confidently.&lt;/p&gt;

&lt;p&gt;And maybe invest in a better notification sound for your production monitoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ready to build with confidence?&lt;/strong&gt; Check out &lt;a href="https://docs.kinde.com" rel="noopener noreferrer"&gt;Kinde's documentation&lt;/a&gt; for production-ready authentication that you can trust—no AI guessing required. And explore &lt;a href="https://docs.convex.dev" rel="noopener noreferrer"&gt;Convex's guides&lt;/a&gt; for type-safe, real-time databases that integrate seamlessly with Kinde's battle-tested security.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>performance</category>
      <category>kinde</category>
    </item>
    <item>
      <title>Why Your SaaS Can't Charge Per-Seat Anymore (And What Replaces It)</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Tue, 27 Jan 2026 22:32:25 +0000</pubDate>
      <link>https://dev.to/sholajegede/why-your-saas-cant-charge-per-seat-anymore-and-what-replaces-it-d70</link>
      <guid>https://dev.to/sholajegede/why-your-saas-cant-charge-per-seat-anymore-and-what-replaces-it-d70</guid>
      <description>&lt;p&gt;Your biggest customer just emailed. The one paying $5,000/month for 50 seats.&lt;/p&gt;

&lt;p&gt;"We're scaling down to 10 seats next month. Your AI assistant automated 40 positions. Reducing our plan to $1,000/month."&lt;/p&gt;

&lt;p&gt;You just lost 80% of revenue from your best customer because your product worked too well.&lt;/p&gt;

&lt;p&gt;This isn't rare anymore. It's happening every week to SaaS companies that bundle AI features into per-seat pricing. Meanwhile, companies switching to hybrid models are seeing 38% higher revenue growth. The gap between those who adapt and those who don't is widening fast.&lt;/p&gt;

&lt;p&gt;Per-seat pricing made perfect sense for twenty years. Value scaled with headcount. Charge $50 per user monthly, serve them for $2, pocket $48 in gross profit per seat. Clean economics that investors loved.&lt;/p&gt;

&lt;p&gt;Then AI changed everything. When your customer deploys an AI agent that does the work of five people, they get 5x the value but only pay for one seat. Your revenue drops 80% while their value increases 400%. You've built the classic innovator's dilemma: your best feature destroys your business model.&lt;/p&gt;

&lt;p&gt;The costs are worse than anyone expected. 84% of enterprises saw gross margin erosion exceeding 6% from unmetered AI infrastructure costs. Some companies watched margins collapse from 75% to 40% overnight after bundling unlimited AI features. Mid-market enterprises now spend $85,521 monthly on AI, up 36% from 2024, and that number keeps climbing.&lt;/p&gt;

&lt;p&gt;When you offer unlimited AI at a flat per-seat price, you're not just being generous. You're creating adverse selection. The customers who choose your "unlimited" plan are precisely the ones who plan to use it heavily. Your pricing model attracts the customers who will destroy your margins.&lt;/p&gt;

&lt;p&gt;A document analysis company learned this the hard way. They offered unlimited AI processing at $20/seat. One customer with 100 seats processed 50,000 documents monthly. At $0.08 per document in LLM costs, that's $4,000 in expenses against $2,000 in revenue. They lost $2,000 every month on their "best" customer.&lt;/p&gt;

&lt;p&gt;This article walks through the three pricing models that actually work in 2026: pure usage-based, credit-based hybrid, and outcome-based. Then I'll show you exactly how to implement hybrid pricing with Kinde, which handles authentication, billing, metering, and customer portals in one platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Math That Broke Per-Seat Pricing
&lt;/h2&gt;

&lt;p&gt;Let's look at what made per-seat pricing so beautiful, and why AI shattered it.&lt;/p&gt;

&lt;p&gt;Traditional B2B SaaS in 2020 had economics that belonged in a textbook. Revenue per seat: $100/month. Cost to serve one additional user: about $2/month. Gross margin: 98%. Scale that to 1,000 customers with 20 seats each and you're looking at $2M in monthly revenue against $40K in costs. Pure profit of $1.96M. Investors poured money into SaaS because the marginal cost of serving another customer was basically zero.&lt;/p&gt;

&lt;p&gt;Then you add AI document analysis. Marketing loves it, customers love it, so you bundle it into existing plans as "unlimited AI processing." It becomes your main differentiator.&lt;/p&gt;

&lt;p&gt;Here's a typical customer scenario: 50 employees at $100/seat gives you $5,000/month in revenue. Each employee processes 20 documents daily, which means 21,000 documents monthly.&lt;/p&gt;

&lt;p&gt;Your cost per document breaks down like this: GPT-4 API call ($0.06), vector database lookup ($0.01), Claude API summary ($0.04), PDF generation ($0.01). Total: $0.12 per document.&lt;/p&gt;

&lt;p&gt;Monthly AI costs: 21,000 × $0.12 = $2,520.&lt;/p&gt;

&lt;p&gt;Now look at your new economics. Revenue: $5,000/month. Traditional COGS: $100/month. AI COGS: $2,520/month. Your gross margin just dropped to 47.6%.&lt;/p&gt;

&lt;p&gt;You went from a 98% margin business to a 48% margin business overnight.&lt;/p&gt;

&lt;p&gt;But it gets worse because not all customers use AI features equally. Look at three customers on the exact same plan:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customer A&lt;/strong&gt; processes 2,000 documents/month. AI costs: $240. Margin: 95%. Still great.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customer B&lt;/strong&gt; processes 21,000 documents/month. AI costs: $2,520. Margin: 48%. Painful but viable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customer C&lt;/strong&gt; processes 85,000 documents/month. AI costs: $10,200. Margin: -104%. You're paying them $5,200 every month to be their customer.&lt;/p&gt;

&lt;p&gt;I've seen this play out in real companies. An AI writing assistant charged $50/seat/month for unlimited generation. They expected 500 generations per user monthly. One customer with 30 marketing team members hit 4,200 generations per user. That's 126,000 total generations.&lt;/p&gt;

&lt;p&gt;At $0.14 per generation, their monthly AI cost was $17,640. Revenue from that customer: $1,500. They were losing $16,140 every month. Three months later, 18% of their customer base showed similar usage patterns. Revenue was growing 15% month-over-month while margins collapsed from 89% to 31%.&lt;/p&gt;

&lt;p&gt;And here's the other problem nobody talks about: AI doesn't just cost more, it also reduces the seats customers need. A customer success platform adds AI ticket resolution. Before: 10 reps handling 300 tickets daily, paying you $750/month. After: AI handles 80% of tickets, they only need 2 reps, paying you $150/month.&lt;/p&gt;

&lt;p&gt;The customer saves 80% while maintaining the same service level. You lose $600/month from a customer who's getting more value than ever.&lt;/p&gt;

&lt;p&gt;If you haven't audited this yet, run these queries tonight: Your top 10% of customers by AI feature usage. Actual cost to serve them (LLM calls, vector DB, GPU compute). Gross margin per customer.&lt;/p&gt;

&lt;p&gt;I guarantee at least 3-5 of your top AI users have negative margins. You're subsidizing them with revenue from other customers.&lt;/p&gt;

&lt;p&gt;The solution isn't to remove AI features or limit usage arbitrarily. AI costs are variable and usage-dependent, so your revenue model needs to match. Companies using hybrid pricing models are seeing 21% median growth rates while pure per-seat models are struggling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Pricing Models That Actually Work
&lt;/h2&gt;

&lt;p&gt;So if per-seat pricing is broken, what replaces it? Three models are working in 2026, each suited for different types of products.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pure Usage-Based Pricing
&lt;/h3&gt;

&lt;p&gt;The simplest model: you pay for exactly what you consume. No base fees, no seat minimums. Just usage multiplied by a unit price.&lt;/p&gt;

&lt;p&gt;Common metrics include API calls per 1,000, tokens processed, compute minutes, outputs generated, or gigabytes processed. Clay built their entire pricing model around this. Their credits directly correlate with costs from underlying data providers. A LinkedIn email lookup costs more credits than a domain lookup because it costs Clay more to provide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This works well when&lt;/strong&gt; you're building infrastructure or platforms, selling to developers, usage varies 100x+ between customers, or you have sophisticated technical buyers who understand consumption-based pricing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The upside:&lt;/strong&gt; Perfect cost alignment, fair for customers, enables natural product-led growth, and captures all the upside from AI adoption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The downside:&lt;/strong&gt; Budget unpredictability scares away some customers, implementation is complex, can slow initial adoption, and requires excellent tooling to pull off well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Credit-Based Hybrid Pricing
&lt;/h3&gt;

&lt;p&gt;This is where most B2B SaaS companies are landing. You charge a base subscription fee for platform access, then layer on usage-based pricing using credits as the currency.&lt;/p&gt;

&lt;p&gt;The structure looks like this: base subscription of $X/month covers platform access, seats, core features, and support. Z credits are included each month. Different actions cost different amounts of credits. If you exceed your included credits, you pay $Y per additional credit.&lt;/p&gt;

&lt;p&gt;Out of 500 companies in the PricingSaaS 500 Index, 79 now offer credit models, up from just 35 at the end of 2024. That's 126% year-over-year growth.&lt;/p&gt;

&lt;p&gt;Here's a typical setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STARTER - $99/month
- 5 seats
- 1,000 credits/month
- Standard features
- Overage: $0.15/credit

PROFESSIONAL - $299/month
- 20 seats
- 5,000 credits/month
- Advanced features
- Overage: $0.12/credit  
- 25% credit rollover
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;This works well when&lt;/strong&gt; you're B2B SaaS with AI features, need predictable revenue, customers need budget certainty, you have a mix of light and heavy users, or you're transitioning away from per-seat pricing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The upside:&lt;/strong&gt; Predictable base revenue, captures usage upside, psychologically easier for customers than pure usage-based, simpler enterprise sales, and creates natural expansion paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The downside:&lt;/strong&gt; Credits can confuse customers, requires sophisticated metering infrastructure, determining credit valuation is tricky, and you need clear rollover policies.&lt;/p&gt;

&lt;p&gt;According to Maxio's 2025 Pricing Trends Report, hybrid models outperformed pure models with a 21% median growth rate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Outcome-Based Pricing
&lt;/h3&gt;

&lt;p&gt;The most sophisticated model: charge for results, not usage. Instead of charging per seat or per API call, you charge $0.99 per ticket autonomously resolved by AI.&lt;/p&gt;

&lt;p&gt;Examples in the wild: customer support at $0.99 per autonomous resolution, sales automation at $50 per qualified meeting booked, content generation at $5 per published article, fraud detection at $0.10 per transaction prevented.&lt;/p&gt;

&lt;p&gt;Gartner projected that 30% of enterprise SaaS would incorporate outcome-based components by 2025, up from 15% in 2022. Companies like Intercom and Zendesk are pioneering this shift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This works well when&lt;/strong&gt; outcomes are clearly measurable, your AI is mature and reliable (90%+ success rates), you're selling to risk-averse buyers, or the outcome value is 10x+ your delivery cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The upside:&lt;/strong&gt; Perfect value alignment, removes adoption friction, commands premium pricing, enables natural expansion, and creates a strong competitive moat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The downside:&lt;/strong&gt; Hard to implement, requires mature AI, contracts get complex, revenue can be volatile, and audit overhead is significant.&lt;/p&gt;

&lt;p&gt;For most B2B SaaS companies reading this, credit-based hybrid pricing is your best bet. It balances revenue predictability with fair usage-based pricing, makes sales easier, and gives you clear paths to expand revenue over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Implement Hybrid Pricing with Kinde
&lt;/h2&gt;

&lt;p&gt;Here's where most founders hit a wall. You need to track usage, manage subscriptions, sync with Stripe, build customer portals, handle feature flags, and somehow keep it all in sync. Most companies end up duct-taping together 3-4 different services.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=1&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; built everything you need in one platform: metered features for usage-based billing, fixed charges for base subscriptions, organization-level billing for B2B multi-tenancy, automatic Stripe sync, self-serve customer portals, feature flags tied to billing tiers, and pricing table builders.&lt;/p&gt;

&lt;p&gt;You configure it in Kinde's dashboard, and it handles the rest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Define Your Value Metrics
&lt;/h3&gt;

&lt;p&gt;Before you touch Kinde, you need to answer three questions about your AI features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: What does your AI actually do?&lt;/strong&gt; Be specific. Not "AI analysis" but "generates 500-word article from bullet points" or "extracts structured data from invoice PDF" or "analyzes sentiment across 1,000 reviews."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second: What's the customer value?&lt;/strong&gt; An article might save 2 hours of writing time, which equals $100+ in value. Invoice extraction saves 15 minutes, maybe $10 in value. You need to understand the time or money your AI saves customers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third: What does it cost you?&lt;/strong&gt; Break down your real infrastructure costs: GPT-4 API calls, Claude API usage, vector database queries, embedding generation, storage. Get specific numbers.&lt;/p&gt;

&lt;p&gt;Here's an example from a document intelligence platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXTRACT_TEXT
- Customer value: Saves 5-10 minutes of manual data entry
- Your cost: $0.035
- Credits you charge: 5 credits

SUMMARIZE_SHORT  
- Customer value: Saves 15-20 minutes of reading
- Your cost: $0.19
- Credits you charge: 10 credits

GENERATE_REPORT
- Customer value: Saves 2-3 hours of analysis  
- Your cost: $1.58
- Credits you charge: 50 credits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Target a 70% gross margin minimum. If your average cost per credit is $0.035 and you want 70% margin, you'd charge $0.12 per credit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Set Up Plans in Kinde
&lt;/h3&gt;

&lt;p&gt;In Kinde, you'll create plans that combine fixed charges (your base subscription fees), metered features (AI credits with included allowances and overage pricing), and unmetered features (access gates for premium features).&lt;/p&gt;

&lt;p&gt;When you publish plans, Kinde automatically syncs everything to Stripe. You don't touch Stripe's dashboard.&lt;/p&gt;

&lt;p&gt;For complete setup instructions, see Kinde's &lt;a href="https://docs.kinde.com/billing/get-started/setup-overview/" rel="noopener noreferrer"&gt;billing setup guide&lt;/a&gt; and &lt;a href="https://docs.kinde.com/billing/manage-plans/create-plans/" rel="noopener noreferrer"&gt;creating plans documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Integrate Kinde Into Your App
&lt;/h3&gt;

&lt;p&gt;Install the Kinde SDK for your stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @kinde-oss/kinde-auth-nextjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Follow the &lt;a href="https://docs.kinde.com/developer-tools/sdks/backend/nextjs-sdk/" rel="noopener noreferrer"&gt;Kinde Next.js SDK documentation&lt;/a&gt; for complete setup.&lt;/p&gt;

&lt;p&gt;You'll need these environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KINDE_CLIENT_ID=your_client_id
KINDE_CLIENT_SECRET=your_client_secret
KINDE_ISSUER_URL=https://yourdomain.kinde.com
KINDE_SITE_URL=http://localhost:3000
KINDE_POST_LOGOUT_REDIRECT_URL=http://localhost:3000
KINDE_POST_LOGIN_REDIRECT_URL=http://localhost:3000/dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Display Your Pricing Table
&lt;/h3&gt;

&lt;p&gt;Kinde automatically generates pricing tables from your plans. Use the &lt;a href="https://docs.kinde.com/billing/pricing-table/pricing-table-builder/" rel="noopener noreferrer"&gt;pricing table builder&lt;/a&gt; to customize the look and feel, then embed it in your registration flow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Track and Consume Credits
&lt;/h3&gt;

&lt;p&gt;This is where the rubber meets the road. When a user performs an AI action, you need to check their organization's plan, perform the action, then report usage back to Kinde.&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="c1"&gt;// app/api/ai-action/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getKindeServerSession&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="s2"&gt;@kinde-oss/kinde-auth-nextjs/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getOrganization&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getKindeServerSession&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;user&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;getUser&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;org&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;getOrganization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;}&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Define credit costs per action&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CREDIT_COSTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;EXTRACT_TEXT&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="na"&gt;SUMMARIZE_SHORT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;GENERATE_REPORT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&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;creditsNeeded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CREDIT_COSTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;CREDIT_COSTS&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="c1"&gt;// Perform the AI operation&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;performAIAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Report usage to Kinde via Management API&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;reportUsageToKinde&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;creditsNeeded&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;creditsUsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;creditsNeeded&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reportUsageToKinde&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgCode&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;credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Kinde Management API: https://docs.kinde.com/kinde-apis/management/&lt;/span&gt;
  &lt;span class="c1"&gt;// Note: You'll need to get the customer_agreement_id from the organization's &lt;/span&gt;
  &lt;span class="c1"&gt;// billing record in Kinde. This is the unique subscription ID in Stripe.&lt;/span&gt;
  &lt;span class="c1"&gt;// You can fetch this via the Kinde Management API or store it when the org subscribes.&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`https://yourdomain.kinde.com/api/v1/billing/meter_usage`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_MANAGEMENT_API_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;customer_agreement_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agreement_xxx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Replace with actual agreement ID&lt;/span&gt;
        &lt;span class="na"&gt;billing_feature_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ai_credits&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;meter_value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="na"&gt;meter_type_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delta&lt;/span&gt;&lt;span class="dl"&gt;"&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="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;Kinde handles the rest. It tracks usage across billing cycles, calculates overages at the end of each month, and automatically bills customers through Stripe. You don't build credit tracking, billing cycle management, or invoice generation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Gate Features Based on Plan
&lt;/h3&gt;

&lt;p&gt;You'll want to restrict certain features to higher-tier plans. Maybe "Advanced AI" only works for Professional and Enterprise customers.&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;getKindeServerSession&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="s2"&gt;@kinde-oss/kinde-auth-nextjs/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getFlag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getKindeServerSession&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;hasAdvancedAI&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;getFlag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;advanced_ai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hasAdvancedAI&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Requires Professional plan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;upgradeUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/billing&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;When someone upgrades from Starter to Professional, the &lt;code&gt;advanced_ai&lt;/code&gt; flag flips to true instantly. No code deployments, no database migrations, no manual updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Let Customers Manage Their Own Billing
&lt;/h3&gt;

&lt;p&gt;The last thing you want is a flood of support tickets asking "How do I upgrade?" or "Where's my invoice?" Kinde provides a hosted portal where customers handle everything themselves: subscription management, usage tracking, payment updates, plan changes.&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="c1"&gt;// app/billing/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BillingPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://yourdomain.kinde.com/billing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;Manage&lt;/span&gt; &lt;span class="nx"&gt;Subscription&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/a&lt;/span&gt;&lt;span class="err"&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;That's it. Link to Kinde's portal and your customers can upgrade, downgrade, update cards, view invoices, and monitor their credit usage without ever contacting support.&lt;/p&gt;

&lt;p&gt;For setup instructions, see the &lt;a href="https://docs.kinde.com/billing/get-started/self-serve-portal-setup/" rel="noopener noreferrer"&gt;self-serve portal documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: Handle Billing Events with Webhooks
&lt;/h3&gt;

&lt;p&gt;When customers upgrade, downgrade, hit usage limits, or cancel, you need to know about it. Kinde sends webhooks for all major billing 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="c1"&gt;// app/api/webhooks/kinde/route.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handlePlanChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;usage.limit_reached&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notifyCustomer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscription.canceled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleCancellation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;Set up webhooks to trigger emails, update internal dashboards, or flag high-value customer changes for your sales team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Avoiding Common Pitfalls
&lt;/h2&gt;

&lt;p&gt;I've watched companies implement hybrid pricing dozens of times. Three mistakes keep showing up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall 1: Making Credits Too Abstract
&lt;/h3&gt;

&lt;p&gt;Finance teams love credits because they're a clean abstraction. Customers hate them because "50 credits" means nothing until they've used your product for weeks.&lt;/p&gt;

&lt;p&gt;Don't just show the number. Show the value:&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;Generate&lt;/span&gt; &lt;span class="nx"&gt;Report&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="err"&gt;•&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;monthly&lt;/span&gt; &lt;span class="nx"&gt;allowance&lt;/span&gt; &lt;span class="err"&gt;•&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="nx"&gt;$150&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/span&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build a calculator on your pricing page. "2,500 credits = 250 summaries or 50 reports." Let people see exactly what they're buying.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall 2: Underestimating Your Costs
&lt;/h3&gt;

&lt;p&gt;Every founder I've talked to underestimates AI costs in the first three months. They price credits based on average usage, then get destroyed by the 95th percentile.&lt;/p&gt;

&lt;p&gt;Always add a 40% buffer to your cost calculations:&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;calculateCredits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pessimisticCost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;bufferedCost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pessimisticCost&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 40% safety buffer&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetMargin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.70&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// 70% gross margin&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pricePerCredit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bufferedCost&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pricePerCredit&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;targetMargin&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;Track your actual costs every week. Not monthly, weekly. LLM pricing changes, usage patterns shift, and new features launch. Adjust your credit prices quarterly based on real data, not assumptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall 3: Unclear Rollover Policies
&lt;/h3&gt;

&lt;p&gt;"Do my credits expire?" is the second most common support question after "How do I upgrade?" Define rollover rules upfront and make them tier-specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Starter:&lt;/strong&gt; No rollover. Credits expire monthly. Clear and simple.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Professional:&lt;/strong&gt; 25% rollover for one month. Rewards consistent usage without gaming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise:&lt;/strong&gt; 50% rollover with no expiration. Enterprise customers expect flexibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't make this complicated. Pick clear rules, document them on your pricing page, and stick to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating Existing Customers Without Losing Them
&lt;/h2&gt;

&lt;p&gt;Changing pricing on existing customers is how you destroy trust and tank retention. I've seen companies lose 20% of their customer base in a single quarter by forcing immediate migration. Don't do that.&lt;/p&gt;

&lt;p&gt;Here are three strategies that actually work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strategy 1: Grandfather Existing Customers (12-18 Months)
&lt;/h3&gt;

&lt;p&gt;Let existing customers stay on per-seat pricing. New customers get the hybrid model. At renewal time, offer migration with 90 days notice and clear communication about why.&lt;/p&gt;

&lt;p&gt;In Kinde, create two plan groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Legacy Plans"&lt;/strong&gt; - Hidden from the pricing table, only visible to grandfathered customers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Current Plans"&lt;/strong&gt; - Shown to all new signups&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This gives you time to prove the new model works, gather data on usage patterns, and build confidence before asking customers to switch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strategy 2: Show Them the Math (The "You'll Save Money" Approach)
&lt;/h3&gt;

&lt;p&gt;Run historical usage analysis on each customer. If someone on a $750/month per-seat plan actually uses 1,800 credits/month, show them the numbers:&lt;/p&gt;

&lt;p&gt;"Current plan: $750/month. Professional plan: $299 base + $0 overage = $299/month. You save $451/month."&lt;/p&gt;

&lt;p&gt;Send personalized emails with their specific numbers. About 60-80% of customers will voluntarily migrate when you can prove they'll save money. The rest are either high users who know hybrid will cost them more, or they just want stability.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strategy 3: Guarantee No Increase (The Safety Net)
&lt;/h3&gt;

&lt;p&gt;For customers who must migrate, offer a guarantee: "You'll move to the new pricing at your next renewal. If your costs are higher in the first six months, we'll credit you the difference."&lt;/p&gt;

&lt;p&gt;Track this via Kinde webhooks and issue manual credits through the Management API when needed. In practice, fewer than 5% of customers ever claim this guarantee, but offering it reduces churn from 15% to under 3%.&lt;/p&gt;

&lt;p&gt;The key principle: never surprise customers, never change pricing mid-contract, and always give them a clear path forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Coming Next
&lt;/h2&gt;

&lt;p&gt;Three trends will reshape SaaS pricing over the next 18 months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outcome-based pricing goes mainstream.&lt;/strong&gt; By mid-2027, expect at least half of AI-native SaaS products to incorporate outcome-based components. The companies building this capability now will have an 18-month head start when customers start demanding it. Start tracking outcomes using Kinde's custom properties on organizations, even if you're not charging for them yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The simplicity backlash is coming.&lt;/strong&gt; As more companies adopt credit models, customers will get fatigued by complexity. Every SaaS tool having its own credit system with different values and rules creates cognitive overhead. The winners will combine usage-based fairness with crystal-clear communication. Make your pricing so simple a finance person can explain it in 30 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI cost deflation forces quarterly reviews.&lt;/strong&gt; Building a top AI model cost $100M in 2022, $5M in 2024, and under $30 in early 2026. As costs drop 90%+ every 18 months, your pricing needs to evolve or you'll be overcharging customers by 10x within two years. Schedule quarterly pricing reviews. Bake this into your roadmap now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your Next Steps
&lt;/h2&gt;

&lt;p&gt;Run these queries in your database tonight:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Top 20 customers by revenue&lt;/li&gt;
&lt;li&gt;Actual AI infrastructure costs per customer (LLM calls, vector DB, GPU)&lt;/li&gt;
&lt;li&gt;Gross margin per customer&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If three or more of your top 20 customers are below 40% gross margin, you have an urgent problem. Every month you wait costs you money you'll never recover.&lt;/p&gt;

&lt;p&gt;Here's your timeline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This week:&lt;/strong&gt; Create a free account at &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=1&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;kinde.com&lt;/a&gt;. Spend an hour exploring the billing features. See how plans work, how metered features get configured, how the pricing table builder works. Don't try to build anything yet, just understand what's possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This month:&lt;/strong&gt; Design your complete pricing structure. Map every AI action to a credit cost with real numbers from your infrastructure costs. Configure all your plans in Kinde. Connect to Stripe in test mode. Run through the entire customer journey from signup to billing portal to webhook handling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This quarter:&lt;/strong&gt; Launch hybrid pricing for new customers only. Keep existing customers on legacy plans with a clear migration timeline. Monitor your margins daily. Watch for patterns in who upgrades, who churns, and who uses way more than you expected.&lt;/p&gt;

&lt;p&gt;Per-seat pricing built the SaaS industry for twenty years because it aligned perfectly with how software delivered value. AI fundamentally changed that equation. Value no longer scales with headcount. It scales with outcomes, with processing power, with intelligent work completed autonomously.&lt;/p&gt;

&lt;p&gt;The companies that recognize this and adapt will capture enormous value. The companies that stick with per-seat pricing will watch margins erode quarter after quarter while competitors eat their lunch with better-aligned pricing.&lt;/p&gt;

&lt;p&gt;Your competitors are already making this transition. The question isn't whether to change, but whether you'll lead or follow.&lt;/p&gt;

&lt;p&gt;Start today: &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=1&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;kinde.com&lt;/a&gt; → &lt;a href="https://docs.kinde.com/billing/get-started/setup-overview/" rel="noopener noreferrer"&gt;billing setup guide&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>saas</category>
      <category>kinde</category>
      <category>software</category>
    </item>
    <item>
      <title>Build a Production-Ready MCP Server in TypeScript</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Wed, 15 Oct 2025 22:03:10 +0000</pubDate>
      <link>https://dev.to/sholajegede/build-a-production-ready-ai-native-mcp-server-in-typescript-2034</link>
      <guid>https://dev.to/sholajegede/build-a-production-ready-ai-native-mcp-server-in-typescript-2034</guid>
      <description>&lt;h2&gt;
  
  
  What is the Model Context Protocol (MCP)?
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol (MCP) is a framework that allows AI assistants like Cursor or Claude to interact with external tools, APIs, and databases. Think of it as a bridge that lets your AI assistant actually do things in the real world: fetch data, update records, process payments, or manage authentication.&lt;/p&gt;

&lt;p&gt;With MCP servers, you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Handle user authentication and sessions&lt;/li&gt;
&lt;li&gt;Work with databases (queries, migrations, CRUD)&lt;/li&gt;
&lt;li&gt;Manage billing flows (free tiers, upgrades, subscriptions)&lt;/li&gt;
&lt;li&gt;Connect to external APIs (emails, third-party data, integrations)&lt;/li&gt;
&lt;li&gt;Bundle multiple tools into a single AI-accessible server&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Use the MCP Starter Kit?
&lt;/h2&gt;

&lt;p&gt;Building an MCP server from scratch means setting up all of the above — authentication, database connections, billing, APIs, error handling, and deployment pipelines. That’s a lot of moving parts.&lt;/p&gt;

&lt;p&gt;The MCP Starter Kit simplifies the process by giving you:&lt;/p&gt;

&lt;h3&gt;
  
  
  Instant Setup
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;One command bootstraps an MCP server&lt;/li&gt;
&lt;li&gt;Preconfigured authentication with Kinde&lt;/li&gt;
&lt;li&gt;Database setup via Neon PostgreSQL&lt;/li&gt;
&lt;li&gt;Built-in billing system&lt;/li&gt;
&lt;li&gt;Multiple ready-to-go templates (blog, ecommerce, CRM, todo)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Production-Ready Features
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Secure auth flows&lt;/li&gt;
&lt;li&gt;Database migrations &amp;amp; schema management&lt;/li&gt;
&lt;li&gt;Error handling and logging&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Rate limiting &amp;amp; security headers&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Testing setup out of the box&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Developer Experience
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;TypeScript support&lt;/li&gt;
&lt;li&gt;Hot reloading for dev&lt;/li&gt;
&lt;li&gt;Cursor AI integration&lt;/li&gt;
&lt;li&gt;Deployment-ready config&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What You'll Build
&lt;/h2&gt;

&lt;p&gt;By the end of this tutorial, you'll create a complete MCP application with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication System → User login, registration, and session management&lt;/li&gt;
&lt;li&gt;Database Integration → PostgreSQL with automatic schema setup&lt;/li&gt;
&lt;li&gt;CRUD Operations → Create, read, update, and delete functionality&lt;/li&gt;
&lt;li&gt;Billing System → Freemium model with upgrade flows&lt;/li&gt;
&lt;li&gt;AI Integration → Seamless Cursor AI integration&lt;/li&gt;
&lt;li&gt;Production Deployment → Ready-to-deploy application&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before starting, ensure you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 18+ (&lt;a href="https://nodejs.org" rel="noopener noreferrer"&gt;download&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;npm or yarn&lt;/li&gt;
&lt;li&gt;Cursor IDE (&lt;a href="https://cursor.sh" rel="noopener noreferrer"&gt;download&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Git&lt;/li&gt;
&lt;li&gt;Basic TypeScript knowledge (helpful but optional)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setting Up Your Development Environment
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Install Node.js and npm
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node --version
npm --version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If missing, download and install from &lt;a href="https://nodejs.org" rel="noopener noreferrer"&gt;nodejs.org&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Install Cursor IDE
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Visit &lt;a href="https://cursor.sh" rel="noopener noreferrer"&gt;cursor.sh&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Download and install Cursor&lt;/li&gt;
&lt;li&gt;Sign up for a free account&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 3: Create Development Accounts
&lt;/h3&gt;

&lt;p&gt;You'll need accounts for: &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Neon Database (Free Tier)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Visit &lt;a href="https://neon.tech" rel="noopener noreferrer"&gt;neon.tech&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sign up for a free account&lt;/li&gt;
&lt;li&gt;Create a new database&lt;/li&gt;
&lt;li&gt;Copy your connection string&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Kinde.com (Free Tier)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Visit &lt;a href="https://kinde.com" rel="noopener noreferrer"&gt;kinde.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sign up for a free account&lt;/li&gt;
&lt;li&gt;Create a new application&lt;/li&gt;
&lt;li&gt;Note your client credentials&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Creating Your First MCP Application
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Generate Your Application
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npx mcp-starter-kit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see an interactive prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🚀 MCP Starter Kit
Create MCP applications with authentication, database, and billing

📝 What would you like to name your application? my-awesome-app
📋 Choose your template:
  1. blog      - Blog management system (posts, comments)
  2. ecommerce - E-commerce system (products, orders)
  3. crm       - Customer relationship management (contacts, deals, tasks)
  4. todo      - Simple todo management system

🎯 Enter your choice (1-4): 1
🚀 Creating my-awesome-app with blog template...
✅ Application created successfully!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Install Dependencies
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm install
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step3: Understand the Project Structure
&lt;/h3&gt;

&lt;p&gt;Your generated project will have this structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-awesome-app/
├── package.json              # Dependencies and scripts
├── tsconfig.json             # TypeScript configuration
├── .env.example              # Environment template
├── README.md                 # Project documentation
└── src/
    ├── app-config.ts         # Application configuration
    ├── universal-server.ts   # Main MCP server
    ├── setup-db.ts          # Database setup script
    ├── kinde-auth-server.ts  # Authentication server
    └── core/                 # Core MCP modules
        ├── auth/             # Authentication manager
        ├── database/         # Database manager
        ├── server/           # MCP server core
        └── tools/            # Tool factory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A brief explanation of key files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;universal-server.ts&lt;/code&gt; → your main MCP server that handles all AI interactions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kinde-auth-server.ts&lt;/code&gt; → authentication server for user login/logout&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;setup-db.ts&lt;/code&gt; → database schema creation and migration script&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app-config.ts&lt;/code&gt; → configuration for your specific application template&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;core/&lt;/code&gt; → MCP modules&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Configuring Authentication with Kinde
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Set Up Kinde Application
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Log into your Kinde dashboard&lt;/li&gt;
&lt;li&gt;Create a new application (if you haven’t)&lt;/li&gt;
&lt;li&gt;Configure the following settings:&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;Application Name: Your MCP App&lt;/li&gt;
&lt;li&gt;Redirect URLs: &lt;code&gt;http://localhost:3000/callback&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Logout URLs: &lt;code&gt;http://localhost:3000&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 2: Configure Environment Variables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cp .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Edit your .env file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Kinde Authentication
KINDE_ISSUER_URL=https://your-domain.kinde.com
KINDE_CLIENT_ID=your_client_id
KINDE_CLIENT_SECRET=your_client_secret
JWT_SECRET=super_secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Setting Up Your Database with Neon
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Update your .env file
&lt;/h3&gt;

&lt;p&gt;Grab the with the connection string you copied from your Neon console and paste it in your &lt;code&gt;.env&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Database Configuration
DATABASE_URL=postgresql://username:password@host:port/database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your complete &lt;code&gt;.env&lt;/code&gt; file should look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Database Configuration
DATABASE_URL=postgresql://username:password@host:port/database

# Kinde Authentication
KINDE_ISSUER_URL=https://your-domain.kinde.com
KINDE_CLIENT_ID=your_client_id
KINDE_CLIENT_SECRET=your_client_secret

# JWT Configuration
JWT_SECRET=your_super_secret_jwt_key_here

# Server Configuration
NODE_ENV=development
PORT=3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Initialize Database Schema
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm run setup-db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command will:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Connect to your Neon database&lt;/li&gt;
&lt;li&gt;Create all necessary tables&lt;/li&gt;
&lt;li&gt;Set up indexes and relationships&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Building Your MCP Server
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Start the server:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Start the authentication server:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm run auth-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;code&gt;http://localhost:3000&lt;/code&gt; to test the login flow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Understand the MCP Tools
&lt;/h3&gt;

&lt;p&gt;Your MCP server provides these tools:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication Tools:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;login&lt;/code&gt; → Get authentication URL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;save_token&lt;/code&gt; → Save authentication token&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;logout&lt;/code&gt; → Log out current user&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;refresh_billing_status&lt;/code&gt; → Check billing status&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;CRUD Tools (Blog Template):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;create_post&lt;/code&gt; → Create new blog post&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_posts&lt;/code&gt; → List all blog posts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_post&lt;/code&gt; → Get specific blog post&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;update_post&lt;/code&gt; → Update existing post&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;delete_post&lt;/code&gt; → Delete blog post&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_comment&lt;/code&gt; → Add comment to post&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_comments&lt;/code&gt; → List post comments&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 4: Integrating with Cursor AI
&lt;/h3&gt;

&lt;p&gt;Create or edit &lt;code&gt;~/.cursor/mcp.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "mcpServers": {
    "my-awesome-app": {
      "command": "node",
      "args": ["dist/universal-server.js"],
      "cwd": "/path/to/your/my-awesome-app",
      "env": {
        "DATABASE_URL": "your_database_url",
        "KINDE_ISSUER_URL": "your_kinde_issuer_url",
        "KINDE_CLIENT_ID": "your_kinde_client_id",
        "KINDE_CLIENT_SECRET": "your_kinde_client_secret",
        "JWT_SECRET": "your_jwt_secret"
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After configuring MCP settings, restart Cursor to load the new configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Test AI Integration
&lt;/h3&gt;

&lt;p&gt;In Cursor, you can now use natural language to interact with your MCP application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Create a new blog post titled 'Getting Started with MCP' with content about building AI applications"
"List all my blog posts"
"Update the post with ID 1 to change the title to 'Advanced MCP Techniques'"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Understanding the Billing System
&lt;/h2&gt;

&lt;p&gt;The MCP Starter Kit includes a comprehensive billing system:&lt;/p&gt;

&lt;h3&gt;
  
  
  Free Tier Limits
&lt;/h3&gt;

&lt;p&gt;Each template has configurable free tier limits:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blog Template:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;10 posts (free tier)&lt;/li&gt;
&lt;li&gt;50 comments (free tier)&lt;/li&gt;
&lt;li&gt;Unlimited reads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;E-commerce Template:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;5 products (free tier)&lt;/li&gt;
&lt;li&gt;10 orders (free tier)&lt;/li&gt;
&lt;li&gt;Unlimited customers&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Upgrade Flow
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Usage Tracking → Automatic tracking of free tier usage&lt;/li&gt;
&lt;li&gt;Limit Warnings → Notifications when approaching limits&lt;/li&gt;
&lt;li&gt;Upgrade Prompts → Seamless redirect to billing portal&lt;/li&gt;
&lt;li&gt;Plan Management → Check current plan and usage&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Billing Integration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Check billing status
const billingStatus = await checkBillingStatus(userId);

// Redirect to upgrade
if (billingStatus.needsUpgrade) {
  return redirectToBillingPortal(userId);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Available Templates Deep Dive
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Blog Template
&lt;/h3&gt;

&lt;p&gt;Perfect for content creators and writers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entities:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Posts&lt;/strong&gt; - Title, content, author, published date&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comments&lt;/strong&gt; - Text, author, post relationship&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rich text content support&lt;/li&gt;
&lt;li&gt;Comment moderation&lt;/li&gt;
&lt;li&gt;Author management&lt;/li&gt;
&lt;li&gt;Publication scheduling&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Personal blogs&lt;/li&gt;
&lt;li&gt;Company blogs&lt;/li&gt;
&lt;li&gt;Documentation sites&lt;/li&gt;
&lt;li&gt;News websites&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  E-commerce Template
&lt;/h3&gt;

&lt;p&gt;Ideal for online stores and marketplaces:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entities:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Products&lt;/strong&gt; - Name, description, price, inventory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orders&lt;/strong&gt; - Customer, items, total, status&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order Items&lt;/strong&gt; - Product, quantity, price&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inventory management&lt;/li&gt;
&lt;li&gt;Order tracking&lt;/li&gt;
&lt;li&gt;Customer management&lt;/li&gt;
&lt;li&gt;Payment integration&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Online stores&lt;/li&gt;
&lt;li&gt;Digital marketplaces&lt;/li&gt;
&lt;li&gt;Subscription services&lt;/li&gt;
&lt;li&gt;Product catalogs&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  CRM Template
&lt;/h3&gt;

&lt;p&gt;Perfect for sales teams and businesses:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entities:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Contacts&lt;/strong&gt; - Name, email, company, status&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deals&lt;/strong&gt; - Title, value, stage, contact&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tasks&lt;/strong&gt; - Description, due date, contact&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lead management&lt;/li&gt;
&lt;li&gt;Sales pipeline&lt;/li&gt;
&lt;li&gt;Task automation&lt;/li&gt;
&lt;li&gt;Contact segmentation&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Sales teams&lt;/li&gt;
&lt;li&gt;Customer support&lt;/li&gt;
&lt;li&gt;Lead generation&lt;/li&gt;
&lt;li&gt;Business development&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Todo Template
&lt;/h3&gt;

&lt;p&gt;Great for personal productivity and task management:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entities:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Todos&lt;/strong&gt; - Title, description, completed, due date&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Task prioritization&lt;/li&gt;
&lt;li&gt;Due date management&lt;/li&gt;
&lt;li&gt;Completion tracking&lt;/li&gt;
&lt;li&gt;Category organization&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Personal productivity&lt;/li&gt;
&lt;li&gt;Project management&lt;/li&gt;
&lt;li&gt;Team coordination&lt;/li&gt;
&lt;li&gt;Goal tracking&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Advanced Features and Customization
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Custom Entity Creation
&lt;/h3&gt;

&lt;p&gt;You can extend any template with custom entities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Add a new entity to your template
const customEntity = {
  name: 'custom_entity',
  fields: {
    title: 'string',
    description: 'text',
    created_at: 'timestamp'
  },
  relationships: {
    user_id: 'users.id'
  }
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Custom MCP Tools
&lt;/h3&gt;

&lt;p&gt;Create custom tools for your specific needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Custom tool example
const customTool = {
  name: 'send_email',
  description: 'Send email to user',
  parameters: {
    to: 'string',
    subject: 'string',
    body: 'string'
  },
  handler: async (params) =&amp;gt; {
    // Your custom logic here
    return { success: true };
  }
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  API Rate Limiting
&lt;/h3&gt;

&lt;p&gt;Configure rate limiting for your MCP server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Rate limiting configuration
const rateLimitConfig = {
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  message: 'Too many requests from this IP'
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Deployment and Production Considerations
&lt;/h2&gt;

&lt;p&gt;Once your MCP server is running locally, the next step is to think about deploying it in a secure and production-ready way. Here are the key areas to cover:&lt;/p&gt;

&lt;h3&gt;
  
  
  Environment Configuration
&lt;/h3&gt;

&lt;p&gt;Keep separate environment files for development and production.&lt;/p&gt;

&lt;p&gt;Development (&lt;code&gt;.env.local&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NODE_ENV=development
DATABASE_URL=your_dev_database_url
KINDE_ISSUER_URL=your_dev_kinde_url
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production (&lt;code&gt;.env&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NODE_ENV=production
DATABASE_URL=your_prod_database_url
KINDE_ISSUER_URL=your_prod_kinde_url
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Security Considerations
&lt;/h3&gt;

&lt;p&gt;When moving to production, follow these best practices:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Environment Variables → Never commit secrets or .env files to version control.&lt;/li&gt;
&lt;li&gt;HTTPS → Always serve your app over HTTPS in production.&lt;/li&gt;
&lt;li&gt;CORS → Configure CORS to only allow requests from your domain.&lt;/li&gt;
&lt;li&gt;Rate Limiting → Protect your endpoints from abuse with rate limiting.&lt;/li&gt;
&lt;li&gt;Input Validation → Validate all incoming requests to prevent injection attacks.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Deployment Options
&lt;/h3&gt;

&lt;p&gt;There are several ways to host your MCP server. Here are three common approaches:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Vercel (Recommended)&lt;/strong&gt;&lt;br&gt;
Vercel makes deployment easy with automatic builds and scaling.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Install Vercel CLI
npm i -g vercel

# Deploy your application
vercel --prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Railway&lt;/strong&gt;&lt;br&gt;
Railway provides a simple GitHub integration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Connect your GitHub repository
# Railway will automatically build and deploy your app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Docker&lt;/strong&gt;&lt;br&gt;
For more control, you can containerize your app with Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Which One Should You Choose?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;td&gt;Zero-config, fast deploys, free tier, great for TypeScript apps&lt;/td&gt;
&lt;td&gt;Less control over server environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Railway&lt;/td&gt;
&lt;td&gt;Easy GitHub integration, supports background jobs&lt;/td&gt;
&lt;td&gt;Free tier more limited than Vercel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;td&gt;Full control, portable across any cloud provider&lt;/td&gt;
&lt;td&gt;More setup and maintenance required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For most developers, &lt;strong&gt;Vercel is the fastest way to go live&lt;/strong&gt;. If you want more flexibility or need background jobs, &lt;strong&gt;Railway&lt;/strong&gt; is a good choice. For enterprise-grade control, &lt;strong&gt;Docker&lt;/strong&gt; is the way forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting Common Issues
&lt;/h2&gt;

&lt;h3&gt;
  
  
  MCP Server Not Detected in Cursor
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cursor doesn't recognize your MCP server&lt;/li&gt;
&lt;li&gt;Tools don't appear in Cursor interface&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Solutions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Check &lt;code&gt;~/.cursor/mcp.json&lt;/code&gt; syntax&lt;/li&gt;
&lt;li&gt;Verify environment variables are set&lt;/li&gt;
&lt;li&gt;Restart Cursor completely&lt;/li&gt;
&lt;li&gt;Ensure MCP server is running&lt;/li&gt;
&lt;li&gt;Check Cursor logs for errors&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Authentication Issues
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Login redirects fail&lt;/li&gt;
&lt;li&gt;Tokens not being saved&lt;/li&gt;
&lt;li&gt;Authentication errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Solutions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Verify Kinde credentials in .env&lt;/li&gt;
&lt;li&gt;Check redirect URLs in Kinde dashboard&lt;/li&gt;
&lt;li&gt;Ensure auth server is running on port 3000&lt;/li&gt;
&lt;li&gt;Clear browser cookies and try again&lt;/li&gt;
&lt;li&gt;Check JWT secret is set correctly&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Database Connection Issues
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Database connection errors&lt;/li&gt;
&lt;li&gt;Schema creation fails&lt;/li&gt;
&lt;li&gt;Query timeouts &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Solutions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Verify Neon database URL&lt;/li&gt;
&lt;li&gt;Check database permissions&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;npm run setup-db&lt;/code&gt; to create schema&lt;/li&gt;
&lt;li&gt;Test connection with &lt;code&gt;npm run test-db&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Check network connectivity&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Performance Issues
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Slow response times&lt;/li&gt;
&lt;li&gt;High memory usage&lt;/li&gt;
&lt;li&gt;Timeout errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Solutions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Optimize database queries&lt;/li&gt;
&lt;li&gt;Add database indexes&lt;/li&gt;
&lt;li&gt;Implement caching&lt;/li&gt;
&lt;li&gt;Use connection pooling&lt;/li&gt;
&lt;li&gt;Monitor resource usage&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Best Practices and Tips
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Development Workflow
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Use Version Control → Always use Git for your projects&lt;/li&gt;
&lt;li&gt;Environment Management → Use different environments for dev/staging/prod&lt;/li&gt;
&lt;li&gt;Testing → Write tests for your MCP tools&lt;/li&gt;
&lt;li&gt;Documentation → Document your custom tools and configurations&lt;/li&gt;
&lt;li&gt;Monitoring → Set up logging and monitoring&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Security Best Practices
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Secrets Management → Use environment variables for all secrets&lt;/li&gt;
&lt;li&gt;Input Validation → Validate all inputs from AI and users&lt;/li&gt;
&lt;li&gt;Rate Limiting → Implement rate limiting to prevent abuse&lt;/li&gt;
&lt;li&gt;Authentication → Always verify user authentication&lt;/li&gt;
&lt;li&gt;HTTPS → Use HTTPS in production&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Performance Optimization
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Database Indexing → Add indexes for frequently queried fields&lt;/li&gt;
&lt;li&gt;Connection Pooling → Use connection pooling for database connections&lt;/li&gt;
&lt;li&gt;Caching → Implement caching for frequently accessed data&lt;/li&gt;
&lt;li&gt;Lazy Loading → Load data only when needed&lt;/li&gt;
&lt;li&gt;Monitoring → Monitor performance metrics&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  AI Integration Tips
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Clear Descriptions → Write clear descriptions for your MCP tools&lt;/li&gt;
&lt;li&gt;Error Handling → Provide meaningful error messages&lt;/li&gt;
&lt;li&gt;Context → Include relevant context in responses&lt;/li&gt;
&lt;li&gt;Validation → Validate AI inputs before processing&lt;/li&gt;
&lt;li&gt;Logging → Log all AI interactions for debugging&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion and Next Steps
&lt;/h2&gt;

&lt;p&gt;You’ve just built a production-ready AI-Native MCP server in TypeScript using a Starter Kit. Along the way, you added authentication, database integration, billing, and even connected it to Cursor AI.&lt;/p&gt;

&lt;p&gt;With this foundation, you can now extend your server with custom tools, new entities, and additional integrations to fit your use case.&lt;/p&gt;

&lt;p&gt;Ready to build your own MCP application? Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npx mcp-starter-kit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and start experimenting today.&lt;/p&gt;

&lt;p&gt;If you found this guide helpful, I’d love your support, check out the &lt;a href="https://github.com/sholajegede/mcp-starter-kit" rel="noopener noreferrer"&gt;MCP Starter Kit GitHub repo&lt;/a&gt; and give it a ⭐.&lt;/p&gt;

&lt;p&gt;Every star helps others discover the project and keeps me motivated to build more resources for the community.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>typescript</category>
      <category>mcp</category>
    </item>
    <item>
      <title>One Small UX Fix That Actually Helps</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Mon, 04 Aug 2025 15:00:41 +0000</pubDate>
      <link>https://dev.to/sholajegede/one-small-ux-fix-that-actually-helps-5551</link>
      <guid>https://dev.to/sholajegede/one-small-ux-fix-that-actually-helps-5551</guid>
      <description>&lt;p&gt;When I build software, I try to make the app remember things. So the user doesn’t have to do the same stuff over and over.&lt;/p&gt;

&lt;p&gt;Most apps reset everything on refresh or when you come back later. That gets annoying fast.&lt;/p&gt;

&lt;p&gt;Here’s what I mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You click the same tab again&lt;/li&gt;
&lt;li&gt;You re-select your filter or sort&lt;/li&gt;
&lt;li&gt;You retype the same input or prompt&lt;/li&gt;
&lt;li&gt;You toggle dark mode every single time&lt;/li&gt;
&lt;li&gt;You expand the same panel again and again&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s small stuff, but it adds up. And it makes your app feel worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use &lt;code&gt;localStorage&lt;/code&gt; to remember things
&lt;/h2&gt;

&lt;p&gt;I now save small bits of state in &lt;code&gt;localStorage&lt;/code&gt;. Just enough so the app feels more stable and consistent.&lt;/p&gt;

&lt;p&gt;Here’s what I usually save:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Last open tab&lt;/li&gt;
&lt;li&gt;Dark or light mode&lt;/li&gt;
&lt;li&gt;Draft input or prompt&lt;/li&gt;
&lt;li&gt;Filters or sort settings&lt;/li&gt;
&lt;li&gt;Collapsed/expanded sidebar&lt;/li&gt;
&lt;li&gt;Dismissed modals or popups (usually the ones that require immediate action)&lt;/li&gt;
&lt;li&gt;Recently used colors&lt;/li&gt;
&lt;li&gt;Preview mode (mobile/desktop)&lt;/li&gt;
&lt;li&gt;Selected language&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing too deep or serious, just the things that help someone continue where they left off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example: Save and Load Dark Mode
&lt;/h2&gt;

&lt;p&gt;Here’s a basic example using localStorage to save the dark mode setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Save mode&lt;/span&gt;
&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; 

&lt;span class="c1"&gt;// Load mode&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;savedTheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; 
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;savedTheme&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&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;Or in React:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// assume setTheme updates UI&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toggleTheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;newTheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;setTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newTheme&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newTheme&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;h2&gt;
  
  
  Quick Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Only store things that make the app easier to use&lt;/li&gt;
&lt;li&gt;Don’t save sensitive info (like auth tokens or personal data)&lt;/li&gt;
&lt;li&gt;Give users a way to reset preferences if needed&lt;/li&gt;
&lt;li&gt;Use a small wrapper or custom hook if you’re using this a lot&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  That’s It
&lt;/h2&gt;

&lt;p&gt;This isn’t a big feature. But it makes your app feel smoother. People don’t notice it when it works — they just stop getting annoyed.&lt;/p&gt;

&lt;p&gt;If you’re building something people will use more than once, this matters.&lt;/p&gt;

&lt;p&gt;What else are you saving with localStorage that improves UX? Drop your own patterns below. I’m always curious what others are doing.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ux</category>
      <category>programming</category>
      <category>learning</category>
    </item>
    <item>
      <title>Why Building with Voice Is a UX Design Challenge, Not Just a Tech One</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Mon, 04 Aug 2025 03:09:13 +0000</pubDate>
      <link>https://dev.to/sholajegede/why-building-with-voice-is-a-ux-design-challenge-not-just-a-tech-one-3lh5</link>
      <guid>https://dev.to/sholajegede/why-building-with-voice-is-a-ux-design-challenge-not-just-a-tech-one-3lh5</guid>
      <description>&lt;p&gt;When people think about building with voice, they think about hard problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Real-time transcription&lt;/li&gt;
&lt;li&gt;Latency management&lt;/li&gt;
&lt;li&gt;GPT inference pipelines&lt;/li&gt;
&lt;li&gt;Audio quality, noise filtering, etc&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All valid. All difficult.&lt;/p&gt;

&lt;p&gt;But none of them are what truly broke my first version of Learnflow AI, a voice-first tutor platform powered by &lt;a href="https://vapi.ai/" rel="noopener noreferrer"&gt;Vapi&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The real challenge? UX.&lt;/p&gt;

&lt;p&gt;Because when your user isn't looking at a screen — when they're &lt;em&gt;speaking&lt;/em&gt; instead of typing — you lose most of the affordances we've come to rely on.&lt;/p&gt;

&lt;p&gt;No hover states. No tooltips. No loading spinners.&lt;/p&gt;

&lt;p&gt;And as I learned the hard way: &lt;strong&gt;No clarity.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a breakdown of what went wrong when I first shipped a real-time voice app and how I reworked it to be &lt;em&gt;understandable, usable,&lt;/em&gt; and even &lt;em&gt;delightful&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Voice Tech Is Easy (When Vapi Handles It)
&lt;/h2&gt;

&lt;p&gt;I built the first version of Learnflow AI over a weekend.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vapi handled the entire voice loop: speech-in, text-to-GPT, voice-out&lt;/li&gt;
&lt;li&gt;Convex tracked sessions, user data, and credits&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post15&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; managed auth, billing, and plan-based access control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks to Vapi, I didn’t need to stitch together Whisper, GPT-4, ElevenLabs, and a WebSocket architecture. One agent definition and a &lt;code&gt;vapi.start()&lt;/code&gt; call handled it all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sample flow agent session start:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assistantOverrides&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;variableValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;clientMessages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transcript&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;serverMessages&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="nx"&gt;vapi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;configureAssistant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;assistantOverrides&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But that just gave me the plumbing.&lt;/p&gt;

&lt;p&gt;It didn’t solve what my &lt;em&gt;users&lt;/em&gt; were facing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Went Wrong
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. No Clarity on When the Session Was Active
&lt;/h3&gt;

&lt;p&gt;Vapi is fast — the session starts within seconds. But users had no idea.&lt;/p&gt;

&lt;p&gt;They’d click "Start Session"...&lt;/p&gt;

&lt;p&gt;Then wait.&lt;/p&gt;

&lt;p&gt;Then say, "Hello?"&lt;/p&gt;

&lt;p&gt;Then say it again.&lt;/p&gt;

&lt;p&gt;Why? Because I didn’t give them visual cues.&lt;/p&gt;

&lt;p&gt;There was &lt;em&gt;no feedback&lt;/em&gt; that their voice was being heard, transcribed, and responded to. For a voice interface, that’s a dealbreaker.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Muted Mic Confusion
&lt;/h3&gt;

&lt;p&gt;Vapi offers a &lt;code&gt;setMuted&lt;/code&gt; toggle, but I didn't expose that clearly.&lt;/p&gt;

&lt;p&gt;One user turned off the mic thinking it was &lt;em&gt;ending&lt;/em&gt; the session.&lt;/p&gt;

&lt;p&gt;Another forgot it was off and kept talking. Silence.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. No Transcript = No Confirmation
&lt;/h3&gt;

&lt;p&gt;Even though I was getting real-time transcripts from Vapi, I didn't display them at first.&lt;/p&gt;

&lt;p&gt;Result? Users didn’t know what was being heard, understood, or ignored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They didn’t trust the app.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Before: The Broken Voice UX&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://mermaid.live/edit#pako:eNpFjktrwzAQhP-K2LMT0tbvQ6GxE8ilFJy2UDsH1drEwrZk9CBNQ_57ZUHobXfnm5m9QisZQg7HQZ7bjipD9mUjCHmpi4G3PanMfKtQay7FgSwWz2Rdv0rywbWlA9kism_a9ofZs_ZyUb9rVKSakPaa7ISRpOIDihY9VHionDP2igrdKj65gk6ehddLr2_qQoqj1cjIZ4emc4E7Qz6l6pF5bOOxbb0RTN_fI28KR2qswuFygABOijPIjbIYwIhqpPMK19negMscsYHcjYyqvoFG3JxnouJLyvFuU9KeOsiPdNBusxOjBktOT4r-IygYqkJaYSB_jHwE5Ff4gTyMlqssCqM4iZPwKU3SAC6OyZZJmGVp6I4PUbzK4lsAv750tUyT6PYHCUCAXg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpFjktrwzAQhP-K2LMT0tbvQ6GxE8ilFJy2UDsH1drEwrZk9CBNQ_57ZUHobXfnm5m9QisZQg7HQZ7bjipD9mUjCHmpi4G3PanMfKtQay7FgSwWz2Rdv0rywbWlA9kism_a9ofZs_ZyUb9rVKSakPaa7ISRpOIDihY9VHionDP2igrdKj65gk6ehddLr2_qQoqj1cjIZ4emc4E7Qz6l6pF5bOOxbb0RTN_fI28KR2qswuFygABOijPIjbIYwIhqpPMK19negMscsYHcjYyqvoFG3JxnouJLyvFuU9KeOsiPdNBusxOjBktOT4r-IygYqkJaYSB_jHwE5Ff4gTyMlqssCqM4iZPwKU3SAC6OyZZJmGVp6I4PUbzK4lsAv750tUyT6PYHCUCAXg%3Ftype%3Dpng" width="276" height="614"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Fixed It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Voice UI Is Feedback UI
&lt;/h3&gt;

&lt;p&gt;I rebuilt the voice session component from scratch with one goal:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Always show users what’s happening.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Design Fix 1: Real-Time Transcript Feed
&lt;/h3&gt;

&lt;p&gt;As Vapi emits &lt;code&gt;transcript&lt;/code&gt; messages, I append them to a rolling transcript UI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;vapi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transcript&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transcriptType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;final&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&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;newMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nf"&gt;setMessages&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;newMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;prev&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transcript appears like a conversation thread. This helps users feel heard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design Fix 2: Speaking Animation (Lottie)
&lt;/h3&gt;

&lt;p&gt;When the assistant is speaking, I show a wave animation using Lottie.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;vapi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;speech-start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setIsSpeaking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;vapi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;speech-end&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setIsSpeaking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This became &lt;em&gt;the&lt;/em&gt; signal for active state.&lt;/p&gt;

&lt;p&gt;Users now intuitively know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When it's listening&lt;/li&gt;
&lt;li&gt;When it's thinking&lt;/li&gt;
&lt;li&gt;When it's speaking&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Design Fix 3: Microphone Toggle That Makes Sense
&lt;/h3&gt;

&lt;p&gt;I added a visible mic toggle button:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;toggleMicrophone&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isMuted&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Mic Off&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Mic On&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a tooltip: &lt;em&gt;"Turn this off if you want silence. Your session continues."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  After: Fixed UX Flow
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://mermaid.live/edit#pako:eNpFj0tPwzAQhP_Kaq-0VYG8D0htWk4gIbWARNKDibeJ1cSObIcCVf87jnndvONvZ2ZPWClOmOG-VceqYdrCdlVKgEWRt6I6wMaO2oaMEUruYDq9gWWxqEna7y8DF7DVTJpKi97CkmohzW50WHo4L-6UtYLgmb0RLKTomCUDSsKmJ6oaj-YeXRWPhvSos4MBJrmLdei_u2dXnl0X96KCrarrllyDXBMXFnI1uF5PwojXljy99vRt8XMArCV34Roe-1ozTvCgVedqbxp1lDucYK0Fx8zqgSbYke7YOOJptCrRNtRRiZl7cqYPJZby7HZ6Jl-U6n7XtBrqBrM9a42bhp67g1eCubzuT9UkOWlfF7Or1HtgdsJ3zIJwNk_DIIziKA6ukziZ4MfIzOIgTZPAiZdhNE-j8wQ_fep8lsTh-QvTEZIa" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpFj0tPwzAQhP_Kaq-0VYG8D0htWk4gIbWARNKDibeJ1cSObIcCVf87jnndvONvZ2ZPWClOmOG-VceqYdrCdlVKgEWRt6I6wMaO2oaMEUruYDq9gWWxqEna7y8DF7DVTJpKi97CkmohzW50WHo4L-6UtYLgmb0RLKTomCUDSsKmJ6oaj-YeXRWPhvSos4MBJrmLdei_u2dXnl0X96KCrarrllyDXBMXFnI1uF5PwojXljy99vRt8XMArCV34Roe-1ozTvCgVedqbxp1lDucYK0Fx8zqgSbYke7YOOJptCrRNtRRiZl7cqYPJZby7HZ6Jl-U6n7XtBrqBrM9a42bhp67g1eCubzuT9UkOWlfF7Or1HtgdsJ3zIJwNk_DIIziKA6ukziZ4MfIzOIgTZPAiZdhNE-j8wQ_fep8lsTh-QvTEZIa%3Ftype%3Dpng" width="276" height="710"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Real User Flow Example
&lt;/h2&gt;

&lt;p&gt;Let’s say Joy signs up for Learnflow AI.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;She picks the free plan (10 voice sessions)&lt;/li&gt;
&lt;li&gt;Lands on the dashboard and clicks “Start Session”&lt;/li&gt;
&lt;li&gt;A Lottie animation appears&lt;/li&gt;
&lt;li&gt;She says: “Hey, what’s a HTML?”&lt;/li&gt;
&lt;li&gt;Sees: “You: Hey, what’s a HTML?”&lt;/li&gt;
&lt;li&gt;Hears: “The Hypertext Markup Language is the standard markup language for documents designed to be…”&lt;/li&gt;
&lt;li&gt;Credit drops from 10 → 9 in real-time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then a nudge appears: “You have 9 sessions left. Upgrade for 100/month.”&lt;/p&gt;

&lt;p&gt;She clicks “Upgrade”, gets routed to &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post15&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde’s billing page&lt;/a&gt;, and instantly returns as a Pro user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Convex + Kinde: Infra That Made My App’s UX Better
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Convex: Session + Credit Logic
&lt;/h3&gt;

&lt;p&gt;Every time a session begins, I log it in Convex and deduct a credit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// schema.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineTable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineTable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;startedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// mutation.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;user&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;ctx&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Out of credits&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&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="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sessions&lt;/span&gt;&lt;span class="dl"&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;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&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="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If credits hit 0:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The user is unable to create a new session with Vapi&lt;/li&gt;
&lt;li&gt;Full-screen upgrade modal appears&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Kinde: Role Gating + Plan Sync
&lt;/h3&gt;

&lt;p&gt;I use &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post15&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde’s hosted pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;After signup, users are assigned &lt;code&gt;free&lt;/code&gt; or &lt;code&gt;pro&lt;/code&gt; roles via metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&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;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;starter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;plus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&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;plans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Plans:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plans&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plans&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&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;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plans&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;starter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;starter&lt;/span&gt;&lt;span class="dl"&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;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plans&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;plus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;plus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Plan:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I sync that in Convex for backend enforcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases I Had to Handle
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;User hits 0 credits mid-session:&lt;/strong&gt; Block next attempt with modal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User mutes mic, thinks session is paused.&lt;/strong&gt; Solution: copy + mic color state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User switches tabs mid-session.&lt;/strong&gt; Solution: session timer auto-ends after 60s idle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User upgrades mid-session.&lt;/strong&gt; Solution: full reload refreshes plan + credit count&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final UX Checklist Before Launch
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Real-time transcript feed ✅&lt;/li&gt;
&lt;li&gt;Visual signal for when assistant is speaking ✅&lt;/li&gt;
&lt;li&gt;Sticky credit counter ✅&lt;/li&gt;
&lt;li&gt;Mic toggle with explanation ✅&lt;/li&gt;
&lt;li&gt;Upgrade nudge after session ✅&lt;/li&gt;
&lt;li&gt;Kinde role sync across backend ✅&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Building voice is not just about latency and speech quality&lt;/li&gt;
&lt;li&gt;Voice-first UX is &lt;em&gt;not&lt;/em&gt; like chatbot UX&lt;/li&gt;
&lt;li&gt;UX clarity is &lt;em&gt;everything&lt;/em&gt; when there are no visual anchors&lt;/li&gt;
&lt;li&gt;Trust comes from visibility: show the transcript, show the state&lt;/li&gt;
&lt;li&gt;Feedback loops build confidence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And most of all:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If your user isn't sure whether they're being heard, they won't speak again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Takeaways If You’re Building Voice AI Apps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Don’t launch voice without a feedback loop&lt;/li&gt;
&lt;li&gt;Show users their words (transcript)&lt;/li&gt;
&lt;li&gt;Show agent activity (Lottie or animation)&lt;/li&gt;
&lt;li&gt;Use a backend like Convex to gate usage in real time&lt;/li&gt;
&lt;li&gt;Use &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post15&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's roles to simplify access control&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Let something like Vapi handle the hard infra&lt;/li&gt;
&lt;li&gt;Don't assume your user will "figure it out" — measure their hesistation and guide them&lt;/li&gt;
&lt;li&gt;Build with latency in mind, but design for &lt;strong&gt;confidence&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;Have you tried building voice-first UX?&lt;/p&gt;

&lt;p&gt;Did you run into any of these challenges?&lt;/p&gt;

&lt;p&gt;Drop a comment, let’s compare notes below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Written by &lt;a href="https://dev.to/sholajegede"&gt;Shola Jegede&lt;/a&gt;&lt;/strong&gt;, building Learnflow AI&lt;/p&gt;

&lt;p&gt;Built with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vapi (voice agent abstraction)&lt;/li&gt;
&lt;li&gt;Convex (real-time backend)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post15&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde (auth + billing)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Late nights, live feedback, and lots of learning.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;See you in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>kinde</category>
      <category>ux</category>
    </item>
    <item>
      <title>The Unseen Cost of Speed: What I’d change if I rebuilt my AI SaaS today</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Mon, 04 Aug 2025 00:54:42 +0000</pubDate>
      <link>https://dev.to/sholajegede/the-unseen-cost-of-speed-what-id-change-if-i-rebuilt-my-ai-saas-today-455m</link>
      <guid>https://dev.to/sholajegede/the-unseen-cost-of-speed-what-id-change-if-i-rebuilt-my-ai-saas-today-455m</guid>
      <description>&lt;p&gt;When you build fast, you feel unstoppable. Weekend sprints. Prototype by Friday, live by Sunday. That was my entire approach with Learnflow AI.&lt;/p&gt;

&lt;p&gt;And on the surface? It worked.&lt;/p&gt;

&lt;p&gt;People signed up. Voice sessions ran smoothly. The tech stack (Next.js + Convex + &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post14&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; + Vapi) held its own.&lt;/p&gt;

&lt;p&gt;But a few weeks in, the cracks started to show.&lt;/p&gt;

&lt;p&gt;Not in the UI. Not in the feature set.&lt;/p&gt;

&lt;p&gt;But underneath.&lt;/p&gt;

&lt;p&gt;In the assumptions I baked into my backend. In the flows I hardcoded to get a "demo" feel. In how little room I left for edge cases, upgrades, re-authentications, and returning users.&lt;/p&gt;

&lt;p&gt;This is the post I wish I read &lt;em&gt;before&lt;/em&gt; shipping that MVP.&lt;/p&gt;

&lt;p&gt;It’s not about the speed. It’s about what gets left behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Started: Learnflow AI in One Weekend
&lt;/h2&gt;

&lt;p&gt;The idea was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Let anyone create a voice-based AI tutor that felt custom and responsive.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vapi&lt;/strong&gt;: voice-to-transcript + agent logic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convex&lt;/strong&gt;: backend DB + real-time mutations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kinde&lt;/strong&gt;: &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post14&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;authentication + hosted pricing page&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js App Router&lt;/strong&gt;: frontend + routes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Within 48 hours, I had it working:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sign up&lt;/li&gt;
&lt;li&gt;Pick plan (via Kinde)&lt;/li&gt;
&lt;li&gt;Land on dashboard&lt;/li&gt;
&lt;li&gt;Click "Create Tutor"&lt;/li&gt;
&lt;li&gt;Click "Start Session"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It worked. Mostly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Didn't See Coming
&lt;/h2&gt;

&lt;p&gt;There’s a cost to optimizing for "works now." &lt;/p&gt;

&lt;p&gt;Here were the blind spots that crept up:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Onboarding State Wasn't Persistent
&lt;/h3&gt;

&lt;p&gt;I had a stepper for onboarding users through their first tutor creation. It showed once. But if they refreshed or came back later?&lt;/p&gt;

&lt;p&gt;They started from scratch.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Upgrade Logic Was Too Far Removed
&lt;/h3&gt;

&lt;p&gt;Kinde hosted my pricing table, and plan info was saved to user metadata. But in-app logic had to &lt;em&gt;manually&lt;/em&gt; read and check that. I had upgrade prompts that didn’t appear at the right time, or sometimes, not at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Session Tracking Wasn’t Modular
&lt;/h3&gt;

&lt;p&gt;I tracked session starts and deducted credits... but not always consistently. Retry flows, dropped connections, or users switching devices would cause edge case issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Fixed It: Layer by Layer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Onboarding State Tracking via Convex
&lt;/h3&gt;

&lt;p&gt;Instead of local state or front-end-only logic, I moved onboarding step tracking to the backend.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&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;setOnboardingStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&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="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;onboardingStep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;step&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, no matter where a user logs in from, I can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Resume onboarding&lt;/li&gt;
&lt;li&gt;Branch logic based on their last seen step&lt;/li&gt;
&lt;li&gt;Trigger upgrade nudges at the right moment&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Diagram: Before vs After - Onboarding Flow
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://mermaid.live/edit#pako:eNptj01vgkAURf_K5K3R4ACVsmhSsSYmTT9imzQFFyM8gQjzyGOotsb_3lFru-ms5t6cOyezh4xyhAjWNW2zUrERL9NUC3tuk9cOWdxT0Ym5XorB4EZMkrjEbGPLTNXiUa9IcV7pQsxqVSzPu8mJjJMHEk9MBWPXiYUhxtwCZ6TrVwWrthSz-dvdRSf_88nkuUf-FDHpD9wJ-jUuDLYXoTwbZbIoaWtRZsyMOBKC2OamrdFUpG2lDP6sUOepBgcKrnKIDPfoQIPcqGOE_RFKwZTYYAqRveaKNymk-mA3rdLvRM1lxtQXJURrVXc29W1uLdNK2S_-IVaHHFOvDUTXpxcg2sMOIhnK4TgIpfRHY9cPwpEDnxB57tC9kn7oByPP9p7vHRz4OjndYTgODt8B-ooa" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptj01vgkAURf_K5K3R4ACVsmhSsSYmTT9imzQFFyM8gQjzyGOotsb_3lFru-ms5t6cOyezh4xyhAjWNW2zUrERL9NUC3tuk9cOWdxT0Ym5XorB4EZMkrjEbGPLTNXiUa9IcV7pQsxqVSzPu8mJjJMHEk9MBWPXiYUhxtwCZ6TrVwWrthSz-dvdRSf_88nkuUf-FDHpD9wJ-jUuDLYXoTwbZbIoaWtRZsyMOBKC2OamrdFUpG2lDP6sUOepBgcKrnKIDPfoQIPcqGOE_RFKwZTYYAqRveaKNymk-mA3rdLvRM1lxtQXJURrVXc29W1uLdNK2S_-IVaHHFOvDUTXpxcg2sMOIhnK4TgIpfRHY9cPwpEDnxB57tC9kn7oByPP9p7vHRz4OjndYTgODt8B-ooa%3Ftype%3Dpng" width="784" height="266"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Credit Enforcement as Middleware
&lt;/h3&gt;

&lt;p&gt;Before:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I checked user credits manually inside button click handlers&lt;/li&gt;
&lt;li&gt;Edge cases like double calls or dropped sessions weren’t covered&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I wrapped credit checks inside a dedicated mutation&lt;/li&gt;
&lt;li&gt;That mutation runs before any session can start
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&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;canStartSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&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;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;user&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;ctx&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&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;isPro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&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;hasCredits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;isPro&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;hasCredits&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Used in frontend:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowed&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canStartSession&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;showUpgradeModal&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Diagram: Credit Check Flow
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://mermaid.live/edit#pako:eNpVjc1uwjAQhF9ltdcGlN8S-VCpmENPVVVKD004WPFCLBI7sp1CC7x7ExBC3dPO7jczR6yMJGS4acy-qoX18LEoNQzzXKwcWeCNqnYOln78Lck5ZfQaJpMnmBe8pmoH76YheABuSSrvQGngRn_TYX3NmY_w6UW4G3ECXlzjPkWngIum-Ye-mju5KJa12cOq21ohCd6saTu_xgC3Vklk3vYUYEu2FaPE45hToq-ppRLZsEphdyWW-jx4OqG_jGlvNmv6bY1sIxo3qL6TwtNCiaHpjpCWZLnptUcWJZcIZEc8IIvzeDrL8jhOo1mYZnkU4A-yJJyGj3Gap1mUDPckTc4B_l5Kw2k-y85_yUdz8w" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpVjc1uwjAQhF9ltdcGlN8S-VCpmENPVVVKD004WPFCLBI7sp1CC7x7ExBC3dPO7jczR6yMJGS4acy-qoX18LEoNQzzXKwcWeCNqnYOln78Lck5ZfQaJpMnmBe8pmoH76YheABuSSrvQGngRn_TYX3NmY_w6UW4G3ECXlzjPkWngIum-Ye-mju5KJa12cOq21ohCd6saTu_xgC3Vklk3vYUYEu2FaPE45hToq-ppRLZsEphdyWW-jx4OqG_jGlvNmv6bY1sIxo3qL6TwtNCiaHpjpCWZLnptUcWJZcIZEc8IIvzeDrL8jhOo1mYZnkU4A-yJJyGj3Gap1mUDPckTc4B_l5Kw2k-y85_yUdz8w%3Ftype%3Dpng" width="449" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Modular Session Tracking
&lt;/h3&gt;

&lt;p&gt;Old pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;startSession&lt;/code&gt; just updated credits&lt;/li&gt;
&lt;li&gt;But didn’t store metadata: which tutor, when, from which device&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;New pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&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;addSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;companionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;companions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&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="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sessions&lt;/span&gt;&lt;span class="dl"&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;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;companionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;companionId&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lets me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;See per-user history&lt;/li&gt;
&lt;li&gt;Trigger upgrade nudges based on behavior&lt;/li&gt;
&lt;li&gt;Visualize usage per tutor&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Better Architecture Patterns I Adopted
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Feature Flags
&lt;/h3&gt;

&lt;p&gt;Instead of &lt;code&gt;if (user.plan === "pro")&lt;/code&gt;, I now use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasFeature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;freePlanFeatures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feature&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;Cleaner. Easier to maintain. Centralized.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event Hooks
&lt;/h3&gt;

&lt;p&gt;I wanted better separation between &lt;em&gt;action&lt;/em&gt; and &lt;em&gt;reaction&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Instead of hardcoding &lt;code&gt;showUpgradeModal()&lt;/code&gt; after every failed session start, I used an event emitter pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;session-blocked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&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;showUpgradeModal&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 means I can swap UI reactions later without touching business logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Composable Logic: Credit + Plan
&lt;/h3&gt;

&lt;p&gt;Rather than spreading plan logic in dozens of places, I now compose it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;canUseFeature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;freePlanFeatures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feature&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;h2&gt;
  
  
  Kinde: What Worked, What Didn't
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ✅ What Worked
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Hosted pricing pages = instant flow&lt;/li&gt;
&lt;li&gt;Metadata sync (plan info in session)&lt;/li&gt;
&lt;li&gt;Easy plan switching between free and pro&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⚠️ What Needed Work
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kinde metadata isn’t real-time (had to sync manually on plan switch)&lt;/li&gt;
&lt;li&gt;No built-in usage enforcement&lt;/li&gt;
&lt;li&gt;Needed my own gating via Convex queries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Still, &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post14&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde handled the identity layer well&lt;/a&gt;. I just had to build the enforcement layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently (If Starting Today)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Design credit flows from day one&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Users don’t read docs&lt;/li&gt;
&lt;li&gt;If usage isn’t visible, no one upgrades&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track onboarding + session state in backend&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Frontend-only state is fragile&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make upgrade logic reactive, not static&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Trigger prompts based on &lt;em&gt;what users do&lt;/em&gt;, not what page they’re on&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modularize credit checks&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Never rely on frontend logic alone&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for role-based UI early&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Gating with plan tiers helps prevent overuse&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;MVP speed is great.&lt;/p&gt;

&lt;p&gt;But every speed-up becomes a tradeoff you’ll have to clean up.&lt;/p&gt;

&lt;p&gt;That’s not a failure. It’s just how startups work.&lt;/p&gt;

&lt;p&gt;If you're where I was a few weeks ago:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Launching something fast&lt;/li&gt;
&lt;li&gt;Trying to stay lean&lt;/li&gt;
&lt;li&gt;Unsure how deep to go in pricing logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Just remember:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Ship fast, but make space for your future self.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Make credit systems, upgrade prompts, and session enforcement &lt;strong&gt;composable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Make onboarding state &lt;strong&gt;persistent&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And make it so that your second-time users aren’t starting from scratch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Got questions or shipping your own AI MVP?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Drop a comment or DM me. I'm building Learnflow AI in public, powered by &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post14&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt;, Convex, Vapi, and lessons like these.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>learning</category>
      <category>kinde</category>
      <category>ai</category>
    </item>
    <item>
      <title>How I Designed a Credit System That Actually Makes Users Upgrade</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Mon, 04 Aug 2025 00:28:17 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-i-designed-a-credit-system-that-actually-makes-users-upgrade-59h5</link>
      <guid>https://dev.to/sholajegede/how-i-designed-a-credit-system-that-actually-makes-users-upgrade-59h5</guid>
      <description>&lt;p&gt;In the first 24 hours after launching &lt;a href="https://learnflowai.vercel.app" rel="noopener noreferrer"&gt;Learnflow AI&lt;/a&gt;, I was quietly optimistic. Users signed up. Sessions were started. Feedback was decent.&lt;/p&gt;

&lt;p&gt;But something felt off.&lt;/p&gt;

&lt;p&gt;No one upgraded.&lt;/p&gt;

&lt;p&gt;Despite building a clean onboarding flow, embedding &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post13&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's hosted pricing tables&lt;/a&gt;, and giving users 10 free credits to try the platform, conversion sat at 0%.&lt;/p&gt;

&lt;p&gt;I didn’t just need a payment integration. I needed a pricing model that matched user behavior — and a credit system that made value feel tangible.&lt;/p&gt;

&lt;p&gt;This post is about the system I built, broke, rebuilt, and finally shipped to convert confused trial users into paying customers. It's also about how I used Convex, Kinde, and Vapi to make it all happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Invisible Usage
&lt;/h2&gt;

&lt;p&gt;When I launched Learnflow AI, I decided to make usage credit-based: 1 session = 1 credit. Every user got 10 free credits. Pro plans included 100/month.&lt;/p&gt;

&lt;p&gt;It sounded clean. Scalable. Friendly.&lt;/p&gt;

&lt;p&gt;But my first users weren’t behaving like they understood any of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Here's what I observed:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Users would start a session, then bounce.&lt;/li&gt;
&lt;li&gt;Some never used more than 1 credit.&lt;/li&gt;
&lt;li&gt;A few returned days later and seemed surprised that their credits were gone (initially, I made it available for 14 days to ramp up usage).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The credit system existed. It was tracked. But it wasn’t &lt;em&gt;felt&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That’s when I realized:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Abstract usage doesn’t drive upgrades. Visible, valuable usage does.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 1: Define Usage as a Business Layer
&lt;/h2&gt;

&lt;p&gt;I built Learnflow AI in a single weekend using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Convex&lt;/strong&gt; for backend + real-time reactivity&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post13&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;&lt;strong&gt;Kinde&lt;/strong&gt; for auth + billing logic&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vapi&lt;/strong&gt; to abstract the entire voice session loop&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Credits became the first real business abstraction I needed to enforce.&lt;/p&gt;

&lt;h3&gt;
  
  
  Convex Schema:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;defineTable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="c1"&gt;// 'free' or 'pro'&lt;/span&gt;
  &lt;span class="na"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&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;Every time a session started, I ran a mutation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;creditCost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&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="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;creditCost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Out of credits. Please upgrade.&lt;/span&gt;&lt;span class="dl"&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;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;creditCost&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;This enforced usage. But it still didn’t &lt;em&gt;communicate&lt;/em&gt; usage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Surface Usage in the UI
&lt;/h2&gt;

&lt;p&gt;The first iteration of Learnflow showed no credit balance. Just a dashboard of public tutors and a "Start Session" button.&lt;/p&gt;

&lt;h3&gt;
  
  
  Users were flying blind.
&lt;/h3&gt;

&lt;p&gt;So I shipped three changes:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Sticky Header Counter
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Badge&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Credits Left: &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Badge&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sat in the global header. No tabs. No modals. It was always visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Session Recap Modal
&lt;/h3&gt;

&lt;p&gt;After each session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;"You've used 1 credit. You have &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; remaining."&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Tutor Card Labels
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Label&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Free Access&lt;/span&gt;&lt;span class="dl"&gt;'&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 Feature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Label&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each of these made the system tangible. Users began associating credits with action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Build Pricing Into the Journey
&lt;/h2&gt;

&lt;p&gt;Kinde handles pricing at the account level. When users signed up, they hit a hosted pricing table.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flow:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User signs up
→ Lands on Kinde-hosted pricing table
→ Picks plan (Free or Pro)
→ Metadata stored on user
→ Redirect to Learnflow dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I extracted plan and credit info from Kinde like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useUserContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getAccessTokenRaw&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useKindeAuth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setEntitlements&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;EntitlementsData&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;You are not logged in, please log in.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;fetchEntitlements&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;accessToken&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;getAccessTokenRaw&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&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;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_KINDE_ISSUER_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/account_api/v1/entitlements`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;data&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Entitlements payload:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setEntitlements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;EntitlementsData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Error fetching entitlements:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&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="nf"&gt;fetchEntitlements&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="nx"&gt;getAccessTokenRaw&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;"&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;plans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Plans:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plans&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plans&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&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;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plans&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Plan:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used this plan field to gate features on the frontend and inside Convex mutations.&lt;/p&gt;

&lt;h3&gt;
  
  
  But here's the catch:
&lt;/h3&gt;

&lt;p&gt;Once the user is inside the product, they rarely revisit the pricing table on the billing page.&lt;/p&gt;

&lt;p&gt;So I brought pricing &lt;em&gt;to them&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key UX Prompts:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;If user is out of credits: full-screen modal with "Upgrade to Pro"&lt;/li&gt;
&lt;li&gt;If user clicks a Pro-only tutor: inline modal explaining Pro perks&lt;/li&gt;
&lt;li&gt;If user is on their last credit: banner warning them&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Flow Diagram: Usage + Upgrade System
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://mermaid.live/edit#pako:eNpVkdFP2zAQxv-V0z2XKk3TNcrDJkiAwTY0LYC0JTwc8ZFYJHbkOAXW9n_HcVUh3nz-ft99d_YWKy0YE3xq9UvVkLFwm5UK4LS4G9hALms1wF3_ACcnX-GsyLnlysLvlhRsJMEPqQQ_TIYzT6RFbrXhAyAV_GJLgix5JPVIVvwkJUAryGhoHjUZ4dXMq-dF2srqGXI7zZLzMEitvH7u9YsibdjpqWEh7TBlpFpt-NUzFxOzO2qnG5ItPba8g8vi0PCeegn3Wlb8qffBd6OPbXfwvcgb_eI2rw0Jt4_RXW89e-nnuCoyFqN7ioMDppVy2nxue-XR6-1xoD_ckVRS1d_2k3ztU_-yi8s-6hvt0kuFM6yNFJhYM_IMOzbO60rcTmiJtuGOS0zcUZB5LrFUe-fpSf3TujvajB7rBpMnagdXjb37C84kuaU-EHZfaFI9KovJIgh8D0y2-IpJGIfz9SoOw2ixDqJVvJjhGybLYB58CaM4Wi2W7n4ZLfcz_O9Tg3m8Xu3fAcNbvaI" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpVkdFP2zAQxv-V0z2XKk3TNcrDJkiAwTY0LYC0JTwc8ZFYJHbkOAXW9n_HcVUh3nz-ft99d_YWKy0YE3xq9UvVkLFwm5UK4LS4G9hALms1wF3_ACcnX-GsyLnlysLvlhRsJMEPqQQ_TIYzT6RFbrXhAyAV_GJLgix5JPVIVvwkJUAryGhoHjUZ4dXMq-dF2srqGXI7zZLzMEitvH7u9YsibdjpqWEh7TBlpFpt-NUzFxOzO2qnG5ItPba8g8vi0PCeegn3Wlb8qffBd6OPbXfwvcgb_eI2rw0Jt4_RXW89e-nnuCoyFqN7ioMDppVy2nxue-XR6-1xoD_ckVRS1d_2k3ztU_-yi8s-6hvt0kuFM6yNFJhYM_IMOzbO60rcTmiJtuGOS0zcUZB5LrFUe-fpSf3TujvajB7rBpMnagdXjb37C84kuaU-EHZfaFI9KovJIgh8D0y2-IpJGIfz9SoOw2ixDqJVvJjhGybLYB58CaM4Wi2W7n4ZLfcz_O9Tg3m8Xu3fAcNbvaI%3Ftype%3Dpng" width="437" height="1218"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Handle Edge Cases
&lt;/h2&gt;

&lt;p&gt;A few tricky cases came up once people started returning:&lt;/p&gt;

&lt;h3&gt;
  
  
  Switching Devices
&lt;/h3&gt;

&lt;p&gt;If a user upgraded on one device but their session still had stale data (cached plan or credits), they'd get blocked incorrectly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fetched fresh credit/plan info on each app load&lt;/li&gt;
&lt;li&gt;Added loading gate to dashboard to ensure correct plan before any session began&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Retry After Upgrade
&lt;/h3&gt;

&lt;p&gt;Some users upgraded after hitting 0, then retried but still saw the error modal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On upgrade, re-pulled session from Kinde via a backend sync&lt;/li&gt;
&lt;li&gt;Refreshed local state using &lt;code&gt;useEffect&lt;/code&gt; hook with plan/credit dependency&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Changed After Shipping
&lt;/h2&gt;

&lt;p&gt;Once I made credit usage visible and value obvious, behavior shifted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Numbers (first 30 users):
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;70% used at least 5 credits&lt;/li&gt;
&lt;li&gt;Around 30% upgraded to Pro&lt;/li&gt;
&lt;li&gt;Zero support tickets asking "What are credits?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I didn’t add more features.&lt;/p&gt;

&lt;p&gt;I made the business logic clearer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credits must feel like currency.&lt;/strong&gt; They’re not abstract limits, they’re fuel to your product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upgrades must be contextual.&lt;/strong&gt; Don’t send users to a billing page. Put the prompt where the pain is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata isn’t enough.&lt;/strong&gt; Storing plan = "pro" is easy. Reflecting that in the experience is the work.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A Note on Kinde
&lt;/h2&gt;

&lt;p&gt;Kinde did exactly what I needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hosted signup and pricing table&lt;/li&gt;
&lt;li&gt;Stored plan + metadata per user&lt;/li&gt;
&lt;li&gt;Role-based gating support in frontend logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post13&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;hosted pricing table&lt;/a&gt; alone saved me days.&lt;/p&gt;

&lt;p&gt;But the biggest value wasn’t features — it was &lt;strong&gt;clarity&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Once pricing and access were defined, the rest was just logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;If you're building an AI product — especially one with per-session cost — you can't afford ambiguous usage.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;When does usage happen?&lt;/li&gt;
&lt;li&gt;What do users get?&lt;/li&gt;
&lt;li&gt;What happens when it runs out?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your credit system isn't just backend logic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It's how you translate value.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;If you're launching a usage-based tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What’s your model?&lt;/li&gt;
&lt;li&gt;What’s your feedback loop?&lt;/li&gt;
&lt;li&gt;Where does pricing show up?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s trade notes in the comments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PS:&lt;/strong&gt; Want to try Learnflow AI? Drop a comment also, I’ll send you a link to get early access.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>product</category>
      <category>kinde</category>
      <category>stripe</category>
    </item>
    <item>
      <title>Auth and Billing in One API Call: A Pattern Worth Betting On</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Sun, 03 Aug 2025 16:04:26 +0000</pubDate>
      <link>https://dev.to/sholajegede/auth-and-billing-in-one-api-call-a-pattern-worth-betting-on-57jl</link>
      <guid>https://dev.to/sholajegede/auth-and-billing-in-one-api-call-a-pattern-worth-betting-on-57jl</guid>
      <description>&lt;p&gt;The first real backend decision I made on Learnflow AI wasn’t about speed, performance, or even voice logic.&lt;/p&gt;

&lt;p&gt;It was about &lt;em&gt;simplicity&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;From the start, I knew two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every user should be on a plan (Free or Pro)&lt;/li&gt;
&lt;li&gt;Every plan should define exactly what features the user could access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But here’s the problem: Most tools split these concerns across two completely different services. One API for auth. Another for billing. A third for usage tracking (usually the database). You end up stitching together logic just to answer a question as basic as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Can this user start a session right now?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Learnflow AI isn’t a typical SaaS app. It’s usage-based, voice-powered, and session-restricted. And to make the experience smooth, I didn’t want 5 lookups across 3 APIs.&lt;/p&gt;

&lt;p&gt;So I made a bet:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if billing and access came from the same source of truth?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters (Even for Small Teams)
&lt;/h2&gt;

&lt;p&gt;AI apps aren’t just about signing in and clicking around. They come with real costs.&lt;/p&gt;

&lt;p&gt;For Learnflow AI, every session spins up a voice assistant. The longer the call, the higher the cost.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every user needs usage limits&lt;/li&gt;
&lt;li&gt;Those limits need to reflect their pricing tier&lt;/li&gt;
&lt;li&gt;And the app needs to enforce them in real-time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That meant I had to build a flow that looked like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://mermaid.live/edit#pako:eNpNjU9vgkAQxb_KZK5Vg_ypZA9tFHvopTGhHlrgsHFHIMIu2V1iW-S7d9GYdk4zb97vvQEPShAyPDbqfKi4tvC-zSW4WWd7QxrSupQGXmUB8_kTbLKkosMJdg2X8ACJJlFbU9yIzdWSDOvGhZF4Hm9yMsmXDzIX2GapnTpSMqZWsvhveFMXeMnSSp1h35WaC4KdVm1nC5xhqWuBzOqeZtiSbvl04jDxOdqKWsqRuVVwfcoxl6NjOi4_lWrvmFZ9WSE78sa4q-8Et7StuWv6s5AUpBPVS4ssXl4jkA34hcyP_cUqin0_XK68MJqe38gCb-E9-mEcRsvA6UEYjDP8uZZ6i3gVjb-s_2xS" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpNjU9vgkAQxb_KZK5Vg_ypZA9tFHvopTGhHlrgsHFHIMIu2V1iW-S7d9GYdk4zb97vvQEPShAyPDbqfKi4tvC-zSW4WWd7QxrSupQGXmUB8_kTbLKkosMJdg2X8ACJJlFbU9yIzdWSDOvGhZF4Hm9yMsmXDzIX2GapnTpSMqZWsvhveFMXeMnSSp1h35WaC4KdVm1nC5xhqWuBzOqeZtiSbvl04jDxOdqKWsqRuVVwfcoxl6NjOi4_lWrvmFZ9WSE78sa4q-8Et7StuWv6s5AUpBPVS4ssXl4jkA34hcyP_cUqin0_XK68MJqe38gCb-E9-mEcRsvA6UEYjDP8uZZ6i3gVjb-s_2xS%3Ftype%3Dpng" alt="First flow" width="443" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the auth and billing systems lived separately, this would have been brittle, slow, and error-prone.&lt;/p&gt;

&lt;p&gt;So instead, I chose a system that merged them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack That Let Me Pull This Off
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post12&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt;&lt;/strong&gt; for both auth and plan metadata (+ usage tracking)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convex&lt;/strong&gt; for real-time database to store sessions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vapi&lt;/strong&gt; for voice session orchestration&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why Kinde?
&lt;/h3&gt;

&lt;p&gt;Most auth providers return a token and a user ID. That’s it.&lt;/p&gt;

&lt;p&gt;Kinde goes further: it lets you store &lt;em&gt;plan info&lt;/em&gt; inside the user metadata.&lt;/p&gt;

&lt;p&gt;That means when a user logs in, I can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Show or hide features&lt;/li&gt;
&lt;li&gt;Enforce limits&lt;/li&gt;
&lt;li&gt;Trigger upgrade prompts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All without hitting a separate billing API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walkthrough: One API Call, Full Access Logic
&lt;/h2&gt;

&lt;p&gt;When a user signs in, here’s what happens under the hood:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. User Picks a Plan on Signup
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post12&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde supports hosted pricing tables&lt;/a&gt;. So when a user signs up, they see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free Plan&lt;/li&gt;
&lt;li&gt;Pro Plan ($)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once they select a plan, Kinde adds that to their metadata, which I go on to store into the user’s table in my database.&lt;/p&gt;

&lt;p&gt;No need to run a webhook or background sync.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Metadata Comes with Every Session
&lt;/h3&gt;

&lt;p&gt;Now, anytime the user logs in, their plan info is bundled in.&lt;/p&gt;

&lt;p&gt;So inside Convex, I can write logic like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&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;canStartSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&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;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;user&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;ctx&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&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;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&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;credits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;out_of_credits&lt;/span&gt;&lt;span class="dl"&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s it.&lt;/p&gt;

&lt;p&gt;One call. Full access check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Went Wrong (Before This Pattern)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Mistake 1: Treating Auth and Billing as Separate
&lt;/h3&gt;

&lt;p&gt;My first implementation used a separate billing API and tried to sync plan data into Convex via webhook.&lt;/p&gt;

&lt;p&gt;Problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Race conditions&lt;/li&gt;
&lt;li&gt;Out-of-date plan info&lt;/li&gt;
&lt;li&gt;Complex error handling if sync failed&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Mistake 2: Not Storing Plan Info in Session
&lt;/h3&gt;

&lt;p&gt;Early users would sign up for Pro, but get treated like Free for a few minutes. Why?&lt;/p&gt;

&lt;p&gt;Because the app didn’t read billing metadata until a background sync completed.&lt;/p&gt;

&lt;p&gt;Friction. Confusion. Drop-off.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 3: Hidden Usage Until Failure
&lt;/h3&gt;

&lt;p&gt;Before adding credit counters, users had no idea they were near their limit. So the session would &lt;em&gt;just fail&lt;/em&gt; one day.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Fixed It
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Used &lt;a href="https://kinde.com?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=learnflowai&amp;amp;campaignid=&amp;amp;network=devto&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=post12&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's metadata API&lt;/a&gt; as the billing source of truth&lt;/li&gt;
&lt;li&gt;Moved plan info into session&lt;/li&gt;
&lt;li&gt;Showed credit counters in the UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;UI Changes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dashboard header: &lt;code&gt;You have 3 sessions left&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Tutor page: &lt;code&gt;This tutor requires Pro&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Modal on failure: &lt;code&gt;You're out of credits. Upgrade to continue.&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Full Flow
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://mermaid.live/edit#pako:eNpNkVtvozAQhf_KyM9JlBCyRDxs1UCy7XYvSKSVdoEHB0_ACtjImDZdkv--5lI1frJnzvnmyNOSVDIkLjkW8i3NqdKw92MB5txHIc8EPFcJTKdfLwFPTxAUVFxgEwWKp1xksKeHAmEKT1wwTAbfppOD1z7XqOAnasqophDSV2TXQeH1wJ1CvIAf_aCCgRTg0zo_SKpYcqsKlDSioeL35G3rFV0WTyHVCPtGS3U3kre95w_WF9gZcCPSfBDApuEFQ5Xc6n4Z9Ldoe64KqRCC5mC4g7wedbt-4kMU6u5nQqxrLsXYe-h7j60nxSueuziMa_ByTE9jmsd-ylbIJsvHvgn2faS9SJ7iB9P84QuteHJr_N1okMdP41MU5vLNLCRTlJm8SpaVNg4yIZnijLhaNTghJaqSdk_SdrSY6BxLjIlrroyqU0xicTWeioq_UpYfNtWlJO6RFrV5NZXZGvqcmlmfEjRbVp5shCau4_QI4rbkTFxrbc2c1dqy7IUzt1frxYS8E3c5n82_WPbaXi2Wpr60l9cJ-dcPnc_Wzur6H4tEyL0" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpNkVtvozAQhf_KyM9JlBCyRDxs1UCy7XYvSKSVdoEHB0_ACtjImDZdkv--5lI1frJnzvnmyNOSVDIkLjkW8i3NqdKw92MB5txHIc8EPFcJTKdfLwFPTxAUVFxgEwWKp1xksKeHAmEKT1wwTAbfppOD1z7XqOAnasqophDSV2TXQeH1wJ1CvIAf_aCCgRTg0zo_SKpYcqsKlDSioeL35G3rFV0WTyHVCPtGS3U3kre95w_WF9gZcCPSfBDApuEFQ5Xc6n4Z9Ldoe64KqRCC5mC4g7wedbt-4kMU6u5nQqxrLsXYe-h7j60nxSueuziMa_ByTE9jmsd-ylbIJsvHvgn2faS9SJ7iB9P84QuteHJr_N1okMdP41MU5vLNLCRTlJm8SpaVNg4yIZnijLhaNTghJaqSdk_SdrSY6BxLjIlrroyqU0xicTWeioq_UpYfNtWlJO6RFrV5NZXZGvqcmlmfEjRbVp5shCau4_QI4rbkTFxrbc2c1dqy7IUzt1frxYS8E3c5n82_WPbaXi2Wpr60l9cJ-dcPnc_Wzur6H4tEyL0%3Ftype%3Dpng" alt="Full flow" width="644" height="1441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Pattern Scales
&lt;/h2&gt;

&lt;p&gt;Even though Learnflow AI is small today, this access logic will scale to thousands of users.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No custom billing backend&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No race conditions&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every plan decision is portable&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus, plan upgrades are reflected immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Can Steal from This
&lt;/h2&gt;

&lt;p&gt;If you're building an AI app with usage-based pricing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Choose an auth system that lets you store plan metadata&lt;/li&gt;
&lt;li&gt;Use one backend query to decide access&lt;/li&gt;
&lt;li&gt;Reflect usage visually, early, and often&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Don't treat billing as a separate concern.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It &lt;em&gt;is&lt;/em&gt; product logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thought
&lt;/h2&gt;

&lt;p&gt;It’s easy to over-engineer early.&lt;/p&gt;

&lt;p&gt;You start imagining scale. Edge cases. Payment fails. Rate limits.&lt;/p&gt;

&lt;p&gt;But in the first version, users just want to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can I use this?&lt;/li&gt;
&lt;li&gt;What am I allowed to do?&lt;/li&gt;
&lt;li&gt;What happens when I hit the limit?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you answer those clearly, you win trust.&lt;/p&gt;

&lt;p&gt;That’s what this pattern gave me: &lt;strong&gt;clarity by default.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Auth and billing in one call.&lt;/p&gt;

&lt;p&gt;A pattern worth betting on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Built with:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kinde (Auth + Billing + Metadata + Usage metering)&lt;/li&gt;
&lt;li&gt;Convex (Backend)&lt;/li&gt;
&lt;li&gt;Vapi (Voice sessions)&lt;/li&gt;
&lt;li&gt;Next.js App Router&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>api</category>
      <category>kinde</category>
    </item>
  </channel>
</rss>
