Skip to content

Add Linear-style seat-based billing example#15

Open
joeysabs wants to merge 3 commits intomainfrom
joeysabs/nextjs-seat-billing
Open

Add Linear-style seat-based billing example#15
joeysabs wants to merge 3 commits intomainfrom
joeysabs/nextjs-seat-billing

Conversation

@joeysabs
Copy link
Collaborator

@joeysabs joeysabs commented Jan 21, 2026

Summary

Introduces a new example project demonstrating seat-based billing with Flowglad. Features a Linear-style issue tracker UI with 4-tier pricing (Free, Basic $10/user, Business $16/user, Enterprise custom), dual billing mode supporting both user-based and organization-based subscriptions, monthly/yearly billing toggle with savings display, and resource-based seat management.

Key Features

  • Linear-inspired issue tracker UI as the example application
  • Pricing model with 4 tiers and monthly/yearly variants
  • Dual billing mode (user-based by default, organization-based if active)
  • Monthly/yearly toggle with automatic savings calculations
  • Resource-based billing for seats and team limits
  • Complete authentication flow with BetterAuth
  • Database integration with Drizzle ORM

Also updates @flowglad/nextjs to 0.16.4 across all other example projects for consistency.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a full seat-based billing example: multi‑tier pricing, checkout, subscription management, seat capacity tracking, dashboard with team member management, seat adjustments, sign‑in/sign‑up, create-organization flow, pricing pages, debug page and health check endpoint.
  • Documentation

    • Added README and MIT license for the seat-based billing example; included env/config templates and usage guidance.
  • Chores

    • Bumped @flowglad/nextjs to v0.16.4 across example projects.

✏️ Tip: You can customize this high-level summary in your review settings.

Introduces a new example showcasing seat-based billing with Flowglad, featuring:
- Linear-style issue tracker UI as the example application
- 4-tier pricing model (Free, Basic $10/user, Business $16/user, Enterprise)
- Dual billing mode supporting both user-based and organization-based subscriptions
- Monthly/yearly billing toggle with savings display
- Resource-based seat and team management
- Integration with BetterAuth for authentication and Drizzle for database

Also updates @flowglad/nextjs to 0.16.4 across all other example projects.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Jan 21, 2026

📝 Walkthrough

Walkthrough

Adds a complete seat-based billing Next.js example under nextjs/seat-based-billing/ (auth, DB schema, billing UI, Flowglad integration, pricing model, tooling/config). Also bumps @flowglad/nextjs from 0.16.2 → 0.16.4 in several example package.json files.

Changes

Cohort / File(s) Summary
Dependency updates
\nextjs/generation-based-subscription/package.json`, `nextjs/pay-as-you-go/package.json`, `nextjs/tiered-usage-gated-subscription/package.json`, `nextjs/usage-limit-subscription/package.json``
Bumped @flowglad/nextjs from 0.16.20.16.4.
Project foundation & config
\nextjs/seat-based-billing/.env.example`, `.eslintrc.js`, `.gitignore`, `.prettierrc`, `LICENSE`, `README.md`, `bunfig.toml`, `components.json`, `docker-compose.yml`, `drizzle.config.ts`, `eslint.config.mjs`, `next-env.d.ts`, `next.config.js`, `package.json`, `postcss.config.mjs`, `tsconfig.json``
New project configs, lint/format rules, Docker Postgres, Drizzle config, shadcn/ui components.json, README and MIT license.
DB migrations & schema metadata
\nextjs/seat-based-billing/drizzle/0000_nosy_gamora.sql`, `.../drizzle/meta/0000_snapshot.json`, `.../drizzle/meta/_journal.json``
New SQL migration plus Drizzle snapshot and journal for users, sessions, accounts, organizations, members, invitations, verifications, indexes, and FKs.
Server DB module & schema
\nextjs/seat-based-billing/src/server/db/client.ts`, `.../src/server/db/schema.ts``
New Postgres pool + Drizzle ORM client and full table/schema exports with relations and indexes.
Pricing model
\nextjs/seat-based-billing/pricing.yaml``
New seat-based pricing YAML: Free, Basic (M/Y), Business (M/Y), Enterprise with resources and feature flags.
Auth & Flowglad wiring
\src/lib/auth.ts`, `src/lib/auth-client.ts`, `src/lib/flowglad.ts`, `src/app/api/auth/[...all]/route.ts`, `src/app/api/auth/session/route.ts`, `src/app/api/flowglad/[...path]/route.ts`, `src/app/api/health/route.ts`, `src/middleware.ts``
Better Auth server config and client, Flowglad per-customer factory, API routes wired (auth/session/flowglad/health), and middleware enforcing auth with exemptions. Focus: getCustomerExternalId logic and middleware matcher.
App layout & pages
\src/app/layout.tsx`, `src/app/page.tsx`, `src/app/pricing/page.tsx`, `src/app/sign-in/page.tsx`, `src/app/sign-up/page.tsx`, `src/app/create-org/page.tsx`, `src/app/debug/page.tsx``
Root layout with providers, homepage and pages for pricing, auth, organization creation, and debug.
Client billing UIs & logic
\src/app/home-client.tsx`, `src/app/pricing/pricing-client.tsx`, `src/app/debug/debug-client.tsx`, `src/lib/billing-helpers.ts``
Dashboard with seat/team management, pricing client, and comprehensive billing helper utilities (pricing transforms, usage computations). Review pricing transforms and edge cases.
UI components & providers
\src/components/providers.tsx`, `src/components/navbar.tsx`, `src/components/adjust-subscription-card.tsx`, `src/components/adjust-subscription-grid.tsx`, `src/components/pricing-card.tsx`, `src/components/pricing-cards-grid.tsx`, `src/components/dashboard-skeleton.tsx``
Providers (React Query / Flowglad), Navbar with sign-out and subscription flows, subscription adjust UIs and dialogs, pricing cards/grids, and skeletons. Pay attention to adjustSubscription and cancel/uncancel flows.
UI primitives (shadcn + Radix + Embla)
\src/components/ui/*``
New UI primitives (badge, button, card, carousel, dialog, dropdown, input, progress, skeleton, switch, tooltip) and a carousel; wrappers for Radix/Embla with styling and accessible defaults.
Utilities & hooks
\src/lib/utils.ts`, `src/hooks/use-mobile.ts``
cn utility (clsx + twMerge) and useMobile breakpoint hook.
Styling & global CSS
\src/app/globals.css``
Global CSS with design tokens, dark theme variables, and Tailwind base layers.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Browser as Client (React)
    participant API as Next API
    participant AuthServer as BetterAuth
    participant DB as Postgres/Drizzle
    participant FlowgladSvc as Flowglad

    User->>Browser: Sign-in / browse UI
    Browser->>API: auth requests (/api/auth/*)
    API->>AuthServer: auth actions (create/session)
    AuthServer->>DB: read/write users, sessions, orgs
    DB-->>AuthServer: DB response
    AuthServer-->>API: session / auth response
    API-->>Browser: session established

    Browser->>API: GET/POST /api/flowglad/* (pricing/checkout/adjust)
    API->>AuthServer: getSession(headers) to resolve customer context
    AuthServer->>DB: fetch org or user as needed
    DB-->>AuthServer: customer data
    AuthServer-->>API: resolved customerExternalId
    API->>FlowgladSvc: nextRouteHandler with customerExternalId
    FlowgladSvc-->>API: billing/pricing/checkout response
    API-->>Browser: billing data or redirect
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~90 minutes

Possibly related PRs

Suggested reviewers

  • agreea

Poem

🐰 I hopped in with tokens, yarn, and cheer,
Seats and plans assembled, billing near,
Orgs get tidy, pricing cards take flight,
Developers hop in — everything's right! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding a new Linear-style seat-based billing example project with comprehensive implementation details.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🤖 Fix all issues with AI agents
In `@nextjs/seat-based-billing/next.config.js`:
- Around line 6-13: Remove the unused media.giphy.com remote pattern from the
Next.js image configuration: edit next.config.js and delete the remotePatterns
entry (or just remove the object with hostname 'media.giphy.com') inside the
images.remotePatterns array so images configuration no longer references
media.giphy.com; if the array becomes empty, remove remotePatterns entirely and
leave the images block minimal.

In `@nextjs/seat-based-billing/README.md`:
- Around line 117-139: The fenced code block in README.md's project-structure
lacks a language tag; update the triple-backtick fence to include a language
(e.g., use ```text) so the block is flagged correctly by markdownlint (MD040)
and preserves plain text formatting for the tree diagram.

In `@nextjs/seat-based-billing/src/app/home-client.tsx`:
- Around line 136-142: The current useEffect uses a truthy check on
seatUsage?.capacity which skips initialization when capacity is 0; change the
guard in the effect (the block that references hasInitializedQuantity,
setNewQuantity, and seatUsage?.capacity) to explicitly check for undefined/null
instead of truthiness (e.g., seatUsage?.capacity !== undefined or seatUsage &&
'capacity' in seatUsage) so that a capacity of 0 will still call
setNewQuantity(seatUsage.capacity) and flip hasInitializedQuantity.current to
true.

In `@nextjs/seat-based-billing/src/app/layout.tsx`:
- Around line 21-25: Update the metadata.title value in the exported metadata
object (symbol: metadata) to fix the typo: change "gen-based subscription
example" to "seat-based subscription example" so the title accurately reflects
the project; edit the title property in layout.tsx where metadata is defined.

In `@nextjs/seat-based-billing/src/app/sign-in/page.tsx`:
- Around line 16-29: The onSubmit handler can leave the loading spinner stuck if
authClient.signIn.email throws outside its callbacks; wrap the await call inside
a try/finally in the onSubmit function so setLoading(false) is called regardless
of exceptions. Locate the async function onSubmit and change its flow to:
setLoading(true); setError(null); try { await authClient.signIn.email(...) }
finally { setLoading(false) } (you can keep the onError/onSuccess callbacks
as-is).

In `@nextjs/seat-based-billing/src/app/sign-up/page.tsx`:
- Around line 17-30: The onSubmit handler leaves setLoading(false) outside of a
finally block so loading may not be cleared on unexpected failures; update
onSubmit to wrap the authClient.signUp.email call in try/catch/finally (or at
least try/finally) so setLoading(true) is paired with setLoading(false) in
finally, keep the existing onError callback to setError(ctx.error.message) and
optionally setError in the catch block for non-callback failures, and reference
the onSubmit function and authClient.signUp.email call to locate where to apply
the change.

In `@nextjs/seat-based-billing/src/components/ui/carousel.tsx`:
- Around line 78-130: The keyboard handler handleKeyDown currently always treats
ArrowLeft/ArrowRight as navigation and captures all key events, which breaks
vertical carousels and intercepts inputs; update handleKeyDown to ignore events
when the event.target is an editable element (input, textarea, contentEditable)
and to map ArrowUp/ArrowDown for vertical orientation while using
ArrowLeft/ArrowRight for horizontal (use the orientation
prop/CarouselContext.opts?.axis to determine), and stop using onKeyDownCapture
if you only want focusable container handling; also add tabIndex={0} to the
carousel container to make it keyboard-focusable so handleKeyDown receives keys
only when intended. Ensure references to handleKeyDown, orientation (or
opts.axis), and the container props (onKeyDownCapture -> onKeyDown) are updated
accordingly.

In `@nextjs/seat-based-billing/src/components/ui/dialog.tsx`:
- Around line 69-73: The DialogPrimitive.Close button currently renders as a
plain button which defaults to type="submit" inside forms; update the
DialogPrimitive.Close element (the one rendering XIcon and the sr-only "Close"
span) to explicitly set type="button" so it won't submit any parent forms (keep
the existing classes and children intact).

In `@nextjs/seat-based-billing/src/components/ui/progress.tsx`:
- Around line 8-27: The Progress component allows out-of-range values; clamp the
incoming value to the 0–100 range before using it in ProgressPrimitive.Root and
in the Indicator transform to avoid CSS overflow. Inside the Progress function
compute a safeValue (e.g., treat undefined as 0, then apply Math.max(0,
Math.min(100, value))) and pass that safeValue to the value prop and to the
style transform instead of using (value || 0); update references to
ProgressPrimitive.Root and ProgressPrimitive.Indicator to use safeValue.

In `@nextjs/seat-based-billing/src/lib/auth.ts`:
- Around line 39-42: The comment next to organization() is misleading because
organization() is already enabled in the plugins array; update the plugins block
to either remove/comment out the organization() call if it should not be active
by default, or change the inline comment to reflect that organization() is
active (e.g., "organization() — enabled for organization flow"). Locate the
plugins array and adjust the organization() line or its comment; keep
flowgladPlugin(flowgladConfig) unchanged.

In `@nextjs/seat-based-billing/src/lib/billing-helpers.ts`:
- Around line 328-338: The inline cast indicates the upstream UsageMeter type is
missing the prices property; create a local type alias (e.g.,
UsageMeterWithPrices = UsageMeter & { prices?: Price[] }) and replace the inline
cast of usageMeter with that alias (referencing UsageMeter, Price, usageMeter,
meterWithPrices) to make the intent explicit and avoid ad-hoc casts, and
additionally open an upstream issue/PR against `@flowglad/nextjs` to add prices to
the UsageMeter type so the local alias can be removed in the future.

In `@nextjs/seat-based-billing/src/middleware.ts`:
- Around line 6-12: The middleware currently allowlists specific paths but
misses server-to-server Flowglad routes; update the conditional in middleware.ts
that checks request.nextUrl.pathname (the block that returns
NextResponse.next()) to also permit any path that starts with '/api/flowglad'
(use startsWith('/api/flowglad') alongside the existing checks) so webhook/API
calls from Flowglad are not blocked by auth.
- Around line 15-23: auth.api.getSession can throw and currently unhandled
exceptions will cause middleware 500s; wrap the call to auth.api.getSession in a
try/catch (around the block that calls auth.api.getSession and checks
session?.user) and on any caught error return NextResponse.redirect(new
URL('/sign-in', request.url)) so failures fail securely, keeping the existing
session?.user check and redirect behavior intact.
🧹 Nitpick comments (28)
nextjs/seat-based-billing/docker-compose.yml (1)

1-1: Consider removing the deprecated version field.

The version key is deprecated in Docker Compose V2 and is now ignored. It can be safely removed for a cleaner configuration, though it doesn't cause any issues.

🧹 Suggested cleanup
-version: '3.8'
-
 services:
   postgres:
nextjs/seat-based-billing/next.config.js (1)

3-5: Consider removing ignoreDuringBuilds for production-quality examples.

Disabling ESLint during builds masks potential issues and prevents catching problems in CI. For an example project meant to demonstrate best practices, consider addressing lint errors instead of suppressing them.

If this is intentional for simplicity, consider adding a comment explaining why.

nextjs/seat-based-billing/src/hooks/use-mobile.ts (1)

13-28: Consider using matchMedia for better performance.

The resize event fires frequently during window resizing, which can cause unnecessary re-renders. Using matchMedia with its change event is more performant as it only fires when crossing the breakpoint threshold.

♻️ Optional refactor using matchMedia
 useEffect(() => {
-  const checkMobile = () => {
-    setIsMobile(window.innerWidth < breakpoint)
-  }
+  const mediaQuery = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
 
-  // Check on mount
-  checkMobile()
+  // Set initial value
+  setIsMobile(mediaQuery.matches)
 
-  // Add event listener
-  window.addEventListener('resize', checkMobile)
+  // Listen for changes
+  const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
+  mediaQuery.addEventListener('change', handler)
 
-  // Cleanup
   return () => {
-    window.removeEventListener('resize', checkMobile)
+    mediaQuery.removeEventListener('change', handler)
   }
 }, [breakpoint])
nextjs/seat-based-billing/src/app/api/auth/session/route.ts (1)

11-15: Consider logging the error for debugging purposes.

The error is caught but silently discarded. Adding logging would help diagnose issues during development.

♻️ Optional: Add error logging
-  } catch {
+  } catch (error) {
+    console.error('Session retrieval failed:', error)
     return NextResponse.json(
       { error: 'Internal server error' },
       { status: 500 }
     )
   }
nextjs/seat-based-billing/src/app/create-org/page.tsx (1)

28-34: Avoid logging organization data in production.

The console log can leak sensitive org data to end users. Consider removing it or guarding it behind a development check.

🔧 Suggested change
-        console.log('Organization created:', data)
+        if (process.env.NODE_ENV === 'development') {
+          console.log('Organization created:', data)
+        }
nextjs/seat-based-billing/src/app/debug/debug-client.tsx (2)

17-22: Consider properly typing the session to avoid type assertion.

The type assertion here suggests the BetterAuth session type doesn't include activeOrganizationId from the organization plugin. If this field is expected, consider extending the session type properly in your auth configuration rather than using inline assertions.


39-47: Hardcoded bg-gray-100 won't adapt to dark mode.

The rest of the UI uses theme tokens (e.g., bg-background, text-foreground). Consider using bg-muted or similar theme-aware tokens for consistency with dark mode support.

Example fix
-              <code className="bg-gray-100 px-2 py-1 rounded">
+              <code className="bg-muted px-2 py-1 rounded">

Apply similar changes to other bg-gray-100 usages in this file.

nextjs/seat-based-billing/src/components/ui/tooltip.tsx (1)

21-29: Nested TooltipProvider per tooltip may break shared timing behavior.

Each Tooltip instance creates its own TooltipProvider, which prevents tooltips from sharing skipDelayDuration behavior (where hovering between tooltips doesn't re-trigger the delay). If this is intentional for isolation, consider documenting it; otherwise, consumers should wrap their app in a single TooltipProvider and use TooltipPrimitive.Root directly.

Alternative pattern
// In app layout or providers.tsx
<TooltipProvider delayDuration={0}>
  {children}
</TooltipProvider>

// Then Tooltip can just be:
function Tooltip(props: React.ComponentProps<typeof TooltipPrimitive.Root>) {
  return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
nextjs/seat-based-billing/src/components/ui/button.tsx (1)

40-59: Consider defaulting type="button" to prevent accidental form submissions.

HTML buttons default to type="submit" inside forms. Setting type="button" as a default prevents unexpected form submissions when used outside explicit submit contexts.

Suggested fix
 function Button({
   className,
   variant,
   size,
   asChild = false,
+  type = 'button',
   ...props
 }: React.ComponentProps<'button'> &
   VariantProps<typeof buttonVariants> & {
     asChild?: boolean
   }) {
   const Comp = asChild ? Slot : 'button'

   return (
     <Comp
       data-slot="button"
+      type={asChild ? undefined : type}
       className={cn(buttonVariants({ variant, size, className }))}
       {...props}
     />
   )
 }
nextjs/seat-based-billing/drizzle/0000_nosy_gamora.sql (2)

17-25: Consider adding created_at timestamp to invitations table.

The invitations table lacks a created_at column, which is present in other tables like members and accounts. This timestamp is useful for auditing, tracking invitation age, and implementing expiration cleanup logic.

Suggested schema addition
 CREATE TABLE "invitations" (
 	"id" text PRIMARY KEY NOT NULL,
 	"organization_id" text NOT NULL,
 	"email" text NOT NULL,
 	"role" text,
 	"status" text DEFAULT 'pending' NOT NULL,
 	"expires_at" timestamp NOT NULL,
-	"inviter_id" text NOT NULL
+	"inviter_id" text NOT NULL,
+	"created_at" timestamp DEFAULT now() NOT NULL
 );

27-33: Consider adding indexes on frequently-joined foreign key columns.

The members table's organization_id and user_id columns are foreign keys that will likely be used in JOIN queries (e.g., fetching all members of an organization or all organizations a user belongs to). Similarly, invitations.organization_id would benefit from an index. Without indexes, these queries may result in full table scans as data grows.

Suggested indexes
CREATE INDEX "members_organization_id_idx" ON "members" USING btree ("organization_id");
CREATE INDEX "members_user_id_idx" ON "members" USING btree ("user_id");
CREATE INDEX "invitations_organization_id_idx" ON "invitations" USING btree ("organization_id");

Also applies to: 78-86

nextjs/seat-based-billing/src/components/ui/dropdown-menu.tsx (1)

20-29: DropdownMenuPortal export may be redundant.

DropdownMenuContent (lines 48-58) already wraps its content in a DropdownMenuPrimitive.Portal internally. The separate DropdownMenuPortal wrapper (lines 20-29) is still exported, which could lead to confusion or double-portal nesting if a consumer uses both. Consider either removing the DropdownMenuPortal export or documenting that DropdownMenuContent includes its own portal.

Also applies to: 42-60

nextjs/seat-based-billing/src/lib/billing-helpers.ts (1)

9-9: Export UsageMeterSlug type for callers.

The UsageMeterSlug type is defined locally but not exported. Callers of computeUsageTotal would benefit from importing this type for type-safe usage meter slug values.

Suggested fix
-type UsageMeterSlug = 'fast_generations' | 'hd_video_minutes'
+export type UsageMeterSlug = 'fast_generations' | 'hd_video_minutes'
nextjs/seat-based-billing/src/components/adjust-subscription-grid.tsx (1)

232-243: Use plan.slug as key instead of plan.name.

Using plan.name as the key could cause issues if two products share the same name (e.g., monthly and yearly variants of "Business"). The slug is guaranteed to be unique per price.

Suggested fix
         <div className="grid gap-4 md:grid-cols-2">
           {plans.map((plan) => (
             <AdjustSubscriptionCard
-              key={plan.name}
+              key={plan.slug}
               plan={plan}
               isCurrentPlan={isPlanCurrent(plan)}
               currentPlanPrice={currentPlanPrice}
nextjs/seat-based-billing/src/components/pricing-card.tsx (2)

189-199: Consider using index or unique identifier for feature keys.

Using the feature string itself as the key could cause React warnings or incorrect behavior if a plan has duplicate feature names (e.g., if a feature appears twice). Consider using the index or a combination of index and feature.

Suggested fix
               plan.features.map((feature) => (
-                <li
-                  key={feature}
+              plan.features.map((feature, index) => (
+                <li
+                  key={`${index}-${feature}`}
                   className="flex items-start gap-1.5 md:gap-2"
                 >

164-177: Hardcoded quantity limits.

The min (1) and max (100) quantity values are hardcoded. For a seat-based billing example, these limits may need to vary by plan tier or be configurable via props.

nextjs/seat-based-billing/src/components/pricing-cards-grid.tsx (1)

164-177: Use plan.slug as key instead of plan.name.

Same issue as in AdjustSubscriptionGrid - using plan.name as the key could cause issues with duplicate names. The slug is unique per price.

Suggested fix
               {plans.map((plan) => (
                 <CarouselItem
-                  key={plan.name}
+                  key={plan.slug}
                   className="pl-1 basis-1/2"
                 >
...
           {plans.map((plan) => (
             <PricingCard
-              key={plan.name}
+              key={plan.slug}
               plan={plan}

Also applies to: 185-191

nextjs/seat-based-billing/src/app/pricing/pricing-client.tsx (2)

111-118: Guard against division by zero in savings calculation.

If tier.monthlyPrice is 0, this calculation results in NaN (0/0) or Infinity (for edge cases). While unlikely with real pricing data, defensive coding prevents unexpected UI display issues.

🛡️ Suggested defensive guard
   // Calculate yearly savings
   const yearlySavings = isYearly
-    ? Math.round(
+    ? tier.monthlyPrice > 0
+      ? Math.round(
         ((tier.monthlyPrice * 12 - tier.yearlyPrice) /
           (tier.monthlyPrice * 12)) *
           100
       )
+      : 0
     : 0

274-285: Consider memoizing getCurrentPlanSlug to avoid repeated catalog iteration.

This function is called on every render and iterates through all products and their prices. While acceptable for small catalogs, memoization would improve efficiency.

♻️ Optional memoization
+import { useMemo } from 'react'

// Inside PricingClient:
-  const getCurrentPlanSlug = (): string | null => {
+  const currentPlanSlug = useMemo(() => {
     const sub = billing.currentSubscriptions?.[0]
     if (!sub?.priceId || !billing.catalog) return null

     for (const product of billing.catalog.products) {
       const price = product.prices.find((p) => p.id === sub.priceId)
       if (price?.slug) return price.slug
     }
     return null
-  }
-
-  const currentPlanSlug = getCurrentPlanSlug()
+  }, [billing.currentSubscriptions, billing.catalog])
nextjs/seat-based-billing/src/app/pricing/page.tsx (1)

1-5: Consider adding page-specific metadata for SEO.

The pricing page would benefit from its own metadata export for better SEO, rather than relying solely on the root layout's generic metadata.

📝 Add pricing page metadata
 import { PricingClient } from './pricing-client'
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+  title: 'Pricing - Seat-based Billing Example',
+  description: 'Simple, transparent pricing. Start free and scale as your team grows.',
+}

 export default function PricingPage() {
   return <PricingClient />
 }
nextjs/seat-based-billing/src/app/page.tsx (1)

3-5: Remove unnecessary async keyword.

The function doesn't perform any async operations, so the async keyword is unnecessary.

🧹 Remove async
-export default async function Home() {
+export default function Home() {
   return <HomeClient />
 }
nextjs/seat-based-billing/src/server/db/client.ts (1)

13-22: Consider adding pool configuration for connection limits.

The pool uses only connectionString. For production resilience, consider adding explicit connection limits to prevent pool exhaustion under load.

⚙️ Add pool configuration
 const pool =
   globalThis.__db_pool__ ??
   new Pool({
     connectionString: process.env.DATABASE_URL,
+    max: 20, // Maximum connections in pool
+    idleTimeoutMillis: 30000, // Close idle connections after 30s
+    connectionTimeoutMillis: 5000, // Fail fast if can't connect in 5s
   })
nextjs/seat-based-billing/src/app/layout.tsx (1)

27-44: Remove unnecessary async keyword from RootLayout.

The function doesn't perform any async operations.

🧹 Remove async
-export default async function RootLayout({
+export default function RootLayout({
   children,
 }: PropsWithChildren) {
nextjs/seat-based-billing/drizzle.config.ts (1)

11-13: Empty string fallback for DATABASE_URL may cause confusing errors.

Falling back to '' when DATABASE_URL is undefined will cause drizzle-kit commands to fail with cryptic connection errors rather than a clear "missing environment variable" message. Consider throwing an error or logging a warning.

🛡️ Add validation for DATABASE_URL
 import dotenv from 'dotenv'
 import { defineConfig } from 'drizzle-kit'

 // Prefer .env.local for local development (Next.js convention)
 dotenv.config({ path: '.env.local' })

+if (!process.env.DATABASE_URL) {
+  throw new Error('DATABASE_URL environment variable is required for drizzle-kit')
+}
+
 export default defineConfig({
   out: './drizzle',
   schema: './src/server/db/schema.ts',
   dialect: 'postgresql',
   dbCredentials: {
-    url: process.env.DATABASE_URL ?? '',
+    url: process.env.DATABASE_URL,
   },
 })
nextjs/seat-based-billing/src/components/navbar.tsx (2)

86-92: Refresh billing state after cancel/uncancel mutations.

If useBilling doesn’t automatically update currentSubscriptions, the menu can stay stale (e.g., cancellation tooltip won’t appear until reload). Consider triggering a reload after success, or confirm Flowglad handles it internally.

♻️ Suggested reload after subscription mutations
       await billing.cancelSubscription({
         id: subscriptionId,
         cancellation: {
           timing: 'at_end_of_current_billing_period',
         },
       })
+      if (billing.reload) {
+        await billing.reload()
+      }
@@
       await billing.uncancelSubscription({
         id: subscriptionId,
       })
+      if (billing.reload) {
+        await billing.reload()
+      }

Also applies to: 124-127


158-168: Avoid hard-coding locale for cancellation date display.

Using a fixed locale can produce unexpected formats for non‑US users. Consider using the user’s locale (or a shared formatter).

♻️ Example tweak
-      ).toLocaleDateString('en-US', {
+      ).toLocaleDateString(undefined, {
         year: 'numeric',
         month: 'long',
         day: 'numeric',
       })
nextjs/seat-based-billing/src/lib/flowglad.ts (1)

13-28: Reuse a single headers snapshot for both auth calls.

This avoids duplicate async calls and ensures the same header set is used across org and session lookups.

♻️ Suggested refactor
-    getCustomerDetails: async (externalId: string) => {
+    getCustomerDetails: async (externalId: string) => {
+      const requestHeaders = await headers()
       // Try to get organization first (since customerType is 'organization')
       try {
         const orgData = await auth.api.getFullOrganization({
           query: {
             organizationId: externalId,
           },
-          headers: await headers(),
+          headers: requestHeaders,
         })
@@
           const session = await auth.api.getSession({
-            headers: await headers(),
+            headers: requestHeaders,
           })
nextjs/seat-based-billing/src/app/home-client.tsx (1)

518-542: Avoid hard-coding the max seat cap.

The fixed 100 limit can diverge from plan constraints. Consider deriving maxSeats from the Flowglad pricing model or resource limits exposed by useResource (please confirm the correct field in v0.16.4).

Comment on lines +6 to +13
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'media.giphy.com',
},
],
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if giphy.com images are used anywhere in the project
rg -n "giphy" nextjs/seat-based-billing/

Repository: flowglad/examples

Length of output: 141


🏁 Script executed:

#!/bin/bash
# Search for various giphy references and image URLs
echo "=== Searching for giphy domain ==="
rg -i "media\.giphy\.com" nextjs/seat-based-billing/

echo -e "\n=== Searching for giphy.com ==="
rg -i "giphy\.com" nextjs/seat-based-billing/

echo -e "\n=== Searching for image URLs/sources ==="
rg -i "img.*src|src.*http|image.*url" nextjs/seat-based-billing/ | head -20

echo -e "\n=== Checking for .env or config files with giphy ==="
rg -i "giphy" nextjs/seat-based-billing/ --type env

Repository: flowglad/examples

Length of output: 425


Remove the unused giphy.com remote pattern.

The media.giphy.com hostname configuration is not referenced anywhere in the application and can be safely removed to keep the configuration clean.

🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/next.config.js` around lines 6 - 13, Remove the
unused media.giphy.com remote pattern from the Next.js image configuration: edit
next.config.js and delete the remotePatterns entry (or just remove the object
with hostname 'media.giphy.com') inside the images.remotePatterns array so
images configuration no longer references media.giphy.com; if the array becomes
empty, remove remotePatterns entirely and leave the images block minimal.

Comment on lines +117 to +139
```
src/
├── app/
│ ├── api/ # API routes
│ │ ├── auth/ # BetterAuth routes
│ │ ├── flowglad/ # Flowglad billing routes
│ │ └── health/ # Health check endpoint
│ ├── create-org/ # Organization creation page
│ ├── pricing/ # Pricing page with tier selection
│ ├── sign-in/ # Sign in page
│ └── sign-up/ # Sign up page
├── components/
│ ├── ui/ # shadcn/ui components
│ └── navbar.tsx # Navigation component
├── lib/
│ ├── auth.ts # BetterAuth server config
│ ├── auth-client.ts # BetterAuth client config
│ ├── billing-helpers.ts # Pricing tier helpers
│ └── flowglad.ts # Flowglad server config
└── server/db/
├── client.ts # Drizzle client
└── schema.ts # Database schema
```
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language to the fenced code block (MD040).

The project-structure block lacks a language tag, which triggers markdownlint.

🔧 Suggested change
-```
+```text
 src/
 ├── app/
 │   ├── api/              # API routes
 │   │   ├── auth/         # BetterAuth routes
 │   │   ├── flowglad/     # Flowglad billing routes
 │   │   └── health/       # Health check endpoint
 │   ├── create-org/       # Organization creation page
 │   ├── pricing/          # Pricing page with tier selection
 │   ├── sign-in/          # Sign in page
 │   └── sign-up/          # Sign up page
 ├── components/
 │   ├── ui/               # shadcn/ui components
 │   └── navbar.tsx        # Navigation component
 ├── lib/
 │   ├── auth.ts           # BetterAuth server config
 │   ├── auth-client.ts    # BetterAuth client config
 │   ├── billing-helpers.ts # Pricing tier helpers
 │   └── flowglad.ts       # Flowglad server config
 └── server/db/
     ├── client.ts         # Drizzle client
     └── schema.ts         # Database schema
</details>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.18.1)</summary>

117-117: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

In @nextjs/seat-based-billing/README.md around lines 117 - 139, The fenced code
block in README.md's project-structure lacks a language tag; update the
triple-backtick fence to include a language (e.g., use ```text) so the block is
flagged correctly by markdownlint (MD040) and preserves plain text formatting
for the tree diagram.


</details>

<!-- fingerprinting:phantom:poseidon:eagle -->

<!-- This is an auto-generated comment by CodeRabbit -->

Comment on lines +136 to +142
const hasInitializedQuantity = useRef(false)
useEffect(() => {
if (seatUsage?.capacity && !hasInitializedQuantity.current) {
setNewQuantity(seatUsage.capacity)
hasInitializedQuantity.current = true
}
}, [seatUsage?.capacity])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Initialize quantity when capacity is 0 as well.

The truthy check skips initialization when capacity is 0, leaving newQuantity at 1. This can produce an invalid state for empty plans.

🐛 Fix initialization guard
-  useEffect(() => {
-    if (seatUsage?.capacity && !hasInitializedQuantity.current) {
+  useEffect(() => {
+    if (seatUsage?.capacity != null && !hasInitializedQuantity.current) {
       setNewQuantity(seatUsage.capacity)
       hasInitializedQuantity.current = true
     }
   }, [seatUsage?.capacity])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const hasInitializedQuantity = useRef(false)
useEffect(() => {
if (seatUsage?.capacity && !hasInitializedQuantity.current) {
setNewQuantity(seatUsage.capacity)
hasInitializedQuantity.current = true
}
}, [seatUsage?.capacity])
const hasInitializedQuantity = useRef(false)
useEffect(() => {
if (seatUsage?.capacity != null && !hasInitializedQuantity.current) {
setNewQuantity(seatUsage.capacity)
hasInitializedQuantity.current = true
}
}, [seatUsage?.capacity])
🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/src/app/home-client.tsx` around lines 136 - 142,
The current useEffect uses a truthy check on seatUsage?.capacity which skips
initialization when capacity is 0; change the guard in the effect (the block
that references hasInitializedQuantity, setNewQuantity, and seatUsage?.capacity)
to explicitly check for undefined/null instead of truthiness (e.g.,
seatUsage?.capacity !== undefined or seatUsage && 'capacity' in seatUsage) so
that a capacity of 0 will still call setNewQuantity(seatUsage.capacity) and flip
hasInitializedQuantity.current to true.

Comment on lines +21 to +25
export const metadata: Metadata = {
title: 'gen-based subscription example',
description:
'Next.js starter template with BetterAuth and Flowglad',
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix typo in metadata title: "gen-based" should be "seat-based".

The title says "gen-based subscription example" but should likely be "seat-based subscription example" based on the PR description.

✏️ Fix typo
 export const metadata: Metadata = {
-  title: 'gen-based subscription example',
+  title: 'Seat-based Billing Example',
   description:
     'Next.js starter template with BetterAuth and Flowglad',
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const metadata: Metadata = {
title: 'gen-based subscription example',
description:
'Next.js starter template with BetterAuth and Flowglad',
}
export const metadata: Metadata = {
title: 'Seat-based Billing Example',
description:
'Next.js starter template with BetterAuth and Flowglad',
}
🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/src/app/layout.tsx` around lines 21 - 25, Update
the metadata.title value in the exported metadata object (symbol: metadata) to
fix the typo: change "gen-based subscription example" to "seat-based
subscription example" so the title accurately reflects the project; edit the
title property in layout.tsx where metadata is defined.

Comment on lines +16 to +29
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
await authClient.signIn.email(
{ email, password, callbackURL: '/' },
{
onError: (ctx) => setError(ctx.error.message),
onSuccess: () => router.push('/'),
onRequest: () => {},
}
)
setLoading(false)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n nextjs/seat-based-billing/src/app/sign-in/page.tsx | head -40

Repository: flowglad/examples

Length of output: 1563


🏁 Script executed:

# Check if the file exists and get its structure
ls -la nextjs/seat-based-billing/src/app/sign-in/page.tsx

Repository: flowglad/examples

Length of output: 160


🏁 Script executed:

# Search for authClient definition and configuration
fd -t f -e ts -e tsx nextjs/seat-based-billing | head -20

Repository: flowglad/examples

Length of output: 466


🏁 Script executed:

find nextjs/seat-based-billing -name "auth-client*" -o -name "*auth*" | grep -E "\.(ts|tsx|js)$"

Repository: flowglad/examples

Length of output: 151


🏁 Script executed:

cat -n nextjs/seat-based-billing/src/lib/auth-client.ts | head -50

Repository: flowglad/examples

Length of output: 562


🏁 Script executed:

# Search for how authClient.signIn.email is typically used in the codebase
rg "authClient\.signIn" nextjs/seat-based-billing --type ts --type tsx -B2 -A5

Repository: flowglad/examples

Length of output: 88


🏁 Script executed:

rg "authClient\.signIn" nextjs/seat-based-billing -B2 -A5

Repository: flowglad/examples

Length of output: 723


🏁 Script executed:

# Check package.json to understand better-auth version
cat nextjs/seat-based-billing/package.json | grep -A 5 -B 5 "better-auth"

Repository: flowglad/examples

Length of output: 463


🏁 Script executed:

# Search for other uses of authClient error handling in the codebase
rg "onError" nextjs/seat-based-billing -B3 -A3

Repository: flowglad/examples

Length of output: 1229


🏁 Script executed:

# Check if there are any try/catch patterns around authClient calls in the codebase
rg "try\s*\{" nextjs/seat-based-billing -A 10 | head -50

Repository: flowglad/examples

Length of output: 3894


🏁 Script executed:

# Search for any async/await authClient patterns to see how errors are typically handled
rg "await authClient" nextjs/seat-based-billing -B3 -A10

Repository: flowglad/examples

Length of output: 5142


🌐 Web query:

better-auth signIn email error handling exceptions callback

💡 Result:

Summary + how-to (concise)

  1. Preferred pattern — check returned error:
  • Call signIn.email and inspect the {data, error} result.
  • Example:
    const { data, error } = await authClient.signIn.email({ email, password });
    if (error) { /* show error.message / error.status */ }.
    Use this for most flows. [1][2]
  1. Use onError callback (client fetchOptions / second arg):
  • You can pass callbacks as the second argument (or fetchOptions.onError) to intercept errors centrally:
    await authClient.signIn.email(
    { email, password, callbackURL: "/app" },
    { onError: (ctx) => { if (ctx.error.status === 403) alert("Verify email"); } }
    );
    This receives ctx.error and lets you run custom logic (redirect, UI). [1][3]
  1. Email-verification caveat (callbackURL behavior):
  • When requireEmailVerification is enabled, signIn.email may trigger sendVerificationEmail and you must handle the “verify email” flow (onError or returned error indicate 403 / unverified). Note: signIn.callbackURL behavior has known inconsistencies—some versions redirect client-side instead of embedding callback in the verification email; see reported issue. Handle by checking emailVerified and routing manually if needed. [3][4]
  1. Security / upgrade note:
  • There have been CVE / open-redirect and XSS advisories around callbackURL/error endpoints. Upgrade to a fixed release and validate callback URLs on your side (allowlist, normalize) before passing them. [5][6]

References

  • Basic usage / signIn.email docs. [1]
  • Email & Password docs (signIn props + return shape). [2]
  • Gist / docs examples showing requireEmailVerification and onError usage. [3]
  • GH issue about signIn.callbackURL behavior. [4]
  • Security advisories (open redirect / XSS) and recommended upgrades. [5][6]

If you want, I can: (a) give a minimal try/catch + onError-ready wrapper you can drop into your app, or (b) check which better-auth version you’re using and recommend exact upgrade/fixes.


Prevent loading state from getting stuck on sign‑in errors.
The callback pattern (onError, onSuccess) handles auth failures, but unexpected exceptions (network errors, SDK failures) can bypass cleanup. Add try/finally to ensure setLoading(false) always executes. This aligns with error-handling patterns elsewhere in the codebase (e.g., create-org/page.tsx).

🛠️ Suggested fix
   async function onSubmit(e: React.FormEvent) {
     e.preventDefault()
     setLoading(true)
     setError(null)
-    await authClient.signIn.email(
-      { email, password, callbackURL: '/' },
-      {
-        onError: (ctx) => setError(ctx.error.message),
-        onSuccess: () => router.push('/'),
-        onRequest: () => {},
-      }
-    )
-    setLoading(false)
+    try {
+      await authClient.signIn.email(
+        { email, password, callbackURL: '/' },
+        {
+          onError: (ctx) => setError(ctx.error.message),
+          onSuccess: () => router.push('/'),
+          onRequest: () => {},
+        }
+      )
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Sign in failed')
+    } finally {
+      setLoading(false)
+    }
   }
🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/src/app/sign-in/page.tsx` around lines 16 - 29, The
onSubmit handler can leave the loading spinner stuck if authClient.signIn.email
throws outside its callbacks; wrap the await call inside a try/finally in the
onSubmit function so setLoading(false) is called regardless of exceptions.
Locate the async function onSubmit and change its flow to: setLoading(true);
setError(null); try { await authClient.signIn.email(...) } finally {
setLoading(false) } (you can keep the onError/onSuccess callbacks as-is).

Comment on lines +8 to +27
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -name "progress.tsx" -path "*/components/ui/*" | head -20

Repository: flowglad/examples

Length of output: 458


🏁 Script executed:

fd progress.tsx

Repository: flowglad/examples

Length of output: 446


🏁 Script executed:

git ls-files | grep -E "progress\.tsx"

Repository: flowglad/examples

Length of output: 446


🏁 Script executed:

cat -n ./nextjs/seat-based-billing/src/components/ui/progress.tsx

Repository: flowglad/examples

Length of output: 1044


🏁 Script executed:

for file in ./nextjs/generation-based-subscription/src/components/ui/progress.tsx ./nextjs/pay-as-you-go/src/components/ui/progress.tsx ./nextjs/tiered-usage-gated-subscription/src/components/ui/progress.tsx; do
  echo "=== $file ==="; 
  cat -n "$file";
  echo "";
done

Repository: flowglad/examples

Length of output: 3253


🏁 Script executed:

rg "Progress" ./nextjs/seat-based-billing/src --type tsx --type ts -A 3 -B 1 | grep -A 3 -B 1 "value="

Repository: flowglad/examples

Length of output: 88


🏁 Script executed:

rg "Progress" ./nextjs/seat-based-billing/src -A 3 -B 1 | head -50

Repository: flowglad/examples

Length of output: 3803


🏁 Script executed:

grep -n "progressPercent" ./nextjs/seat-based-billing/src/app/home-client.tsx -B 5 -A 2

Repository: flowglad/examples

Length of output: 956


🏁 Script executed:

find . -name "package.json" -path "*/nextjs/seat-based-billing*" -exec cat {} \;

Repository: flowglad/examples

Length of output: 2519


🌐 Web query:

Radix UI react-progress ProgressPrimitive.Root value prop type constraints

💡 Result:

Short answer — type and constraints

  • Type: value: number | null. [1][2]
  • Semantics: null = indeterminate; a numeric value represents current progress (usually interpreted relative to max). If value ≥ max the component/state becomes "complete". [1][2]
  • Related props: max is a number (the upper bound). Use value in range 0..max for expected behavior; many implementations clamp/interpret values outside that range (but clamp behavior is not enforced by the type). [1][2]

Sources: Radix Progress docs. [1] [2]


🏁 Script executed:

rg "Progress value=" ./nextjs/seat-based-billing/src -A 1 -B 1

Repository: flowglad/examples

Length of output: 424


Add input validation to prevent out-of-range progress values.

The component doesn't clamp the value prop, which can cause CSS transform overflow when values exceed [0, 100]. While current usage in home-client.tsx calculates progressPercent safely, the component itself is vulnerable if value is passed from other sources. The (value || 0) pattern only handles falsy values, not out-of-range numbers (e.g., 150 or −10).

🔧 Suggested change
 function Progress({
   className,
   value,
   ...props
 }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
+  const safeValue =
+    typeof value === 'number' ? Math.min(100, Math.max(0, value)) : 0
   return (
     <ProgressPrimitive.Root
-      value={value}
+      value={safeValue}
       data-slot="progress"
       className={cn(
         'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
         className
       )}
       {...props}
     >
       <ProgressPrimitive.Indicator
         data-slot="progress-indicator"
         className="bg-primary h-full w-full flex-1 transition-all"
-        style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
+        style={{ transform: `translateX(-${100 - safeValue}%)` }}
       />
     </ProgressPrimitive.Root>
   )
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
const safeValue =
typeof value === 'number' ? Math.min(100, Math.max(0, value)) : 0
return (
<ProgressPrimitive.Root
value={safeValue}
data-slot="progress"
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - safeValue}%)` }}
/>
🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/src/components/ui/progress.tsx` around lines 8 -
27, The Progress component allows out-of-range values; clamp the incoming value
to the 0–100 range before using it in ProgressPrimitive.Root and in the
Indicator transform to avoid CSS overflow. Inside the Progress function compute
a safeValue (e.g., treat undefined as 0, then apply Math.max(0, Math.min(100,
value))) and pass that safeValue to the value prop and to the style transform
instead of using (value || 0); update references to ProgressPrimitive.Root and
ProgressPrimitive.Indicator to use safeValue.

Comment on lines +39 to +42
plugins: [
organization(), // Uncomment to test better auth plugin with organization flowglad customer
flowgladPlugin(flowgladConfig),
],
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading comment: organization() is already active.

The comment says "Uncomment to test" but organization() is already being called. Either update the comment to reflect the current state or comment out the plugin if it's not intended to be active by default.

✏️ Suggested comment fix
   plugins: [
-    organization(), // Uncomment to test better auth plugin with organization flowglad customer
+    organization(), // Enables organization support for multi-tenant billing
     flowgladPlugin(flowgladConfig),
   ],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
plugins: [
organization(), // Uncomment to test better auth plugin with organization flowglad customer
flowgladPlugin(flowgladConfig),
],
plugins: [
organization(), // Enables organization support for multi-tenant billing
flowgladPlugin(flowgladConfig),
],
🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/src/lib/auth.ts` around lines 39 - 42, The comment
next to organization() is misleading because organization() is already enabled
in the plugins array; update the plugins block to either remove/comment out the
organization() call if it should not be active by default, or change the inline
comment to reflect that organization() is active (e.g., "organization() —
enabled for organization flow"). Locate the plugins array and adjust the
organization() line or its comment; keep flowgladPlugin(flowgladConfig)
unchanged.

Comment on lines +328 to +338
// Usage prices are now directly on the usage meter
// Cast to access the prices property which is part of the new schema
const meterWithPrices = usageMeter as UsageMeter & {
prices?: Price[]
}
const usagePrice = meterWithPrices.prices?.find(
(price: Price) => price.type === 'usage'
)

return usagePrice ?? null
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find and examine the billing-helpers.ts file
find . -name "billing-helpers.ts" -type f

Repository: flowglad/examples

Length of output: 434


🏁 Script executed:

# Search for UsageMeter type definition and imports
rg "UsageMeter" --type ts --type tsx -B 2 -A 2

Repository: flowglad/examples

Length of output: 88


🏁 Script executed:

# Check for `@flowglad/nextjs` in package.json files
find . -name "package.json" -type f | head -20 | xargs rg "@flowglad/nextjs" 2>/dev/null

Repository: flowglad/examples

Length of output: 676


🏁 Script executed:

# Look for type definitions or interfaces related to UsageMeter
rg "interface UsageMeter|type UsageMeter" --type ts --type tsx

Repository: flowglad/examples

Length of output: 88


🏁 Script executed:

# Read the billing-helpers.ts file around the relevant lines
cat -n ./nextjs/seat-based-billing/src/lib/billing-helpers.ts | head -350 | tail -50

Repository: flowglad/examples

Length of output: 2072


🏁 Script executed:

# Search for UsageMeter type definition without file type restriction
rg "UsageMeter" -B 3 -A 3

Repository: flowglad/examples

Length of output: 24804


🏁 Script executed:

# Look at the imports in billing-helpers.ts
head -50 ./nextjs/seat-based-billing/src/lib/billing-helpers.ts

Repository: flowglad/examples

Length of output: 1459


🏁 Script executed:

# Search for the same cast pattern in other files
rg "as UsageMeter & {" -B 2 -A 2

Repository: flowglad/examples

Length of output: 158


🏁 Script executed:

# Check if there are type extensions or augmentations for UsageMeter
rg "UsageMeter.*prices" -B 2 -A 2

Repository: flowglad/examples

Length of output: 43


🏁 Script executed:

# Look for any type declarations or extensions for UsageMeter in the codebase
rg "declare.*UsageMeter|extend.*UsageMeter|augment.*UsageMeter"

Repository: flowglad/examples

Length of output: 43


🏁 Script executed:

# Check comments or docs about the schema change
rg "schema|prices.*usage.*meter|usage.*meter.*prices" -B 1 -A 1

Repository: flowglad/examples

Length of output: 50375


🏁 Script executed:

# Search for the specific cast pattern using simpler regex
rg "meterWithPrices|prices\?" -B 3 -A 3

Repository: flowglad/examples

Length of output: 6870


🏁 Script executed:

# Look for any comments about schema changes or migration
rg "new schema|schema.*addition|migration|prices.*now" -i -B 2 -A 2

Repository: flowglad/examples

Length of output: 12729


🏁 Script executed:

# Check if prices is actually used anywhere on usage meters without casting
rg "\.prices" -B 2 -A 2 | head -100

Repository: flowglad/examples

Length of output: 8306


Add a local type extension or file upstream issue for UsageMeter type gap.

The cast usageMeter as UsageMeter & { prices?: Price[] } confirms the @flowglad/nextjs UsageMeter type lacks the prices property despite it being present in the runtime schema. While the code is functionally correct and well-documented, this type gap should be resolved either by:

  1. Filing an issue/PR with the upstream @flowglad/nextjs package to include prices in the UsageMeter type definition, or
  2. Creating a local type extension (e.g., type UsageMeterWithPrices = UsageMeter & { prices?: Price[] }) to avoid inline casting and improve clarity.
🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/src/lib/billing-helpers.ts` around lines 328 - 338,
The inline cast indicates the upstream UsageMeter type is missing the prices
property; create a local type alias (e.g., UsageMeterWithPrices = UsageMeter & {
prices?: Price[] }) and replace the inline cast of usageMeter with that alias
(referencing UsageMeter, Price, usageMeter, meterWithPrices) to make the intent
explicit and avoid ad-hoc casts, and additionally open an upstream issue/PR
against `@flowglad/nextjs` to add prices to the UsageMeter type so the local alias
can be removed in the future.

Comment on lines +6 to +12
if (
request.nextUrl.pathname.startsWith('/api/auth') ||
request.nextUrl.pathname === '/sign-in' ||
request.nextUrl.pathname === '/sign-up' ||
request.nextUrl.pathname === '/api/health'
) {
return NextResponse.next()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Allow Flowglad API/webhook routes through the auth middleware.

/api/flowglad/* routes are server-to-server and won’t have a user session, so the middleware will currently block them. Add them to the allowlist.

🔧 Suggested change
   if (
     request.nextUrl.pathname.startsWith('/api/auth') ||
+    request.nextUrl.pathname.startsWith('/api/flowglad') ||
     request.nextUrl.pathname === '/sign-in' ||
     request.nextUrl.pathname === '/sign-up' ||
     request.nextUrl.pathname === '/api/health'
   ) {
     return NextResponse.next()
   }
🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/src/middleware.ts` around lines 6 - 12, The
middleware currently allowlists specific paths but misses server-to-server
Flowglad routes; update the conditional in middleware.ts that checks
request.nextUrl.pathname (the block that returns NextResponse.next()) to also
permit any path that starts with '/api/flowglad' (use
startsWith('/api/flowglad') alongside the existing checks) so webhook/API calls
from Flowglad are not blocked by auth.

Comment on lines +15 to +23
// Validate session using BetterAuth's session validation
const session = await auth.api.getSession({
headers: request.headers,
})

// Protect all other routes - redirect if no valid session
if (!session?.user) {
return NextResponse.redirect(new URL('/sign-in', request.url))
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the middleware.ts file
find . -type f -name "middleware.ts" | head -20

Repository: flowglad/examples

Length of output: 178


🏁 Script executed:

# Also search for the seat-based-billing directory to understand the structure
find . -type d -name "seat-based-billing" 2>/dev/null

Repository: flowglad/examples

Length of output: 88


🌐 Web query:

BetterAuth getSession error handling throws exception

💡 Result:

Summary: getSession may throw a Better Auth APIError when the server returns an error (or when required config like cookieCache isn't set). Handle it with try/catch, inspect the error (APIError / status / body), and fix server-side config if getSession returns null (e.g., enable session.cookieCache). Examples and relevant docs below.

Client-side handling (JS/TS)

  • Wrap calls in try/catch and check error properties:
    try {
    const { data } = await authClient.getSession();
    // use data
    } catch (err) {
    // Better Auth throws an APIError on non-2xx — inspect status/body/message
    if (err?.name === "APIError" || err?.status) {
    // handle auth error (401 -> redirect to login, 403 -> forbidden, etc.)
    } else {
    // network/other error
    }
    }

Server-side notes

  • Some Better Auth APIs require session.cookieCache.enabled to be true for getSession to work in certain contexts; missing that can make getSession return null or behave unexpectedly. If you see null or exceptions, confirm your auth config (session.cookieCache) and middleware usage. [1][2]

Error mapping / filters

  • On the server you can catch APIError and map to HTTP responses (example uses APIError to build JSON error responses). Use this to produce consistent client errors. [3]

Sources

  • Better Auth docs — Session Management / getSession. [1]
  • GitHub issue: auth.api.getSession returning null when session.cookieCache not set. [2]
  • Example mapping APIError to HTTP response (Nest/Express example). [3]

🌐 Web query:

BetterAuth session validation middleware error handling

💡 Result:

Key recommendations and patterns for handling session validation errors in BetterAuth middleware:

  1. Prefer cookie checks in edge middleware (don’t call DB). Use getSessionCookie(request) to detect presence of a session token and short-circuit (redirect/401) — avoid calling auth.api.getSession() inside Next.js edge middleware because that triggers DB calls and slows requests. (reddit.com)

  2. Middleware pattern (Next.js / edge): read cookie, return NextResponse.redirect / NextResponse.next or an unauthorized response when missing/invalid. Wrap cookie access in try/catch to handle unexpected errors and log them. Example sketch:

    • If no cookie → redirect to login / return 401.
    • If cookie exists → let request continue (validate fully on server route if needed). (reddit.com)
  3. Full session validation belongs in server route/request handlers (not edge middleware) — call auth.api.getSession or auth.client.getSession there, and handle errors with structured try/catch:

    • On API errors: map BetterAuth APIError -> return 401 with code/message.
    • On transient/errors (DB down, network): return 502/503 or a clear JSON error and consider retry/backoff. (gist.github.com)
  4. Improve perf & reliability:

    • Add short in-process or LRU cache for session lookups to avoid repeated DB hits for the same token. Invalidate cache on logout/expiry. (github.com)
  5. Error-handling checklist:

    • Differentiate “no session” (401/redirect) from “internal error” (5xx).
    • Return consistent JSON error shape (code/message) for clients to handle automatic logout/redirect.
    • Log full error context (token id, route) but never log raw tokens.
    • Add metrics/alerts on auth failures and DB errors. (gist.github.com)

If you want, tell me your stack (Next.js App Router or pages, Express, edge runtime) and I’ll give a minimal copy-paste middleware + API-handler example with proper try/catch and responses.

Citations:


Wrap getSession in try/catch to prevent middleware 500s on auth errors.

auth.api.getSession throws an APIError when the server returns an error, required configuration (e.g., session.cookieCache) is missing, or network issues occur. Without error handling, these failures cause 500 errors on every request. Catch exceptions and redirect to sign-in to fail securely.

🔧 Suggested change
-  const session = await auth.api.getSession({
-    headers: request.headers,
-  })
+  let session
+  try {
+    session = await auth.api.getSession({
+      headers: request.headers,
+    })
+  } catch {
+    return NextResponse.redirect(new URL('/sign-in', request.url))
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Validate session using BetterAuth's session validation
const session = await auth.api.getSession({
headers: request.headers,
})
// Protect all other routes - redirect if no valid session
if (!session?.user) {
return NextResponse.redirect(new URL('/sign-in', request.url))
}
// Validate session using BetterAuth's session validation
let session
try {
session = await auth.api.getSession({
headers: request.headers,
})
} catch {
return NextResponse.redirect(new URL('/sign-in', request.url))
}
// Protect all other routes - redirect if no valid session
if (!session?.user) {
return NextResponse.redirect(new URL('/sign-in', request.url))
}
🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/src/middleware.ts` around lines 15 - 23,
auth.api.getSession can throw and currently unhandled exceptions will cause
middleware 500s; wrap the call to auth.api.getSession in a try/catch (around the
block that calls auth.api.getSession and checks session?.user) and on any caught
error return NextResponse.redirect(new URL('/sign-in', request.url)) so failures
fail securely, keeping the existing session?.user check and redirect behavior
intact.

Simplified the pricing model to use only the seats resource,
removing teams which required platform changes for proper
subscription adjustment. Updated feature highlights and
documentation to reflect the simpler model.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@nextjs/seat-based-billing/src/app/pricing/pricing-client.tsx`:
- Around line 111-118: The yearlySavings calculation can produce NaN when
tier.monthlyPrice is zero or missing; update the logic in the pricing-client.tsx
where yearlySavings is computed (using isYearly, tier.monthlyPrice,
tier.yearlyPrice) to first check that tier.monthlyPrice is a positive number
before performing the Math.round division, and return 0 (or an appropriate
fallback) when monthlyPrice is 0/undefined to avoid division by zero.
- Around line 122-236: The "Contact Sales" button still triggers paid checkout
because handleSelect calls onSelect whenever priceSlug exists; update the logic
so enterprise tiers do not call onSelect: either change handleSelect to return
early when tier.isEnterprise, or switch the Button onClick to conditionally call
onSelect only for non-enterprise and call a new onContactSales prop (or navigate
to a contact URL) for enterprise; reference the handleSelect function, the
Button with onClick={handleSelect}, tier.isEnterprise, and the onSelect prop
when implementing this change.
- Around line 327-349: The static "Save up to 17%" badge is misleading; compute
the actual savings from the pricing data for the currently selected tier and
render the badge only when savings > 0. Use the selected plan/tier price fields
(e.g., monthlyPrice and yearlyPrice or the existing tiers/selectedPlan
variables) to calculate percentSaved = Math.round((1 - (yearlyPrice/12) /
monthlyPrice) * 100) and replace the hardcoded text with this computed value; if
percentSaved <= 0, omit the <Badge> entirely. Ensure the logic is tied to
isYearly toggling and the same selected tier used elsewhere in
pricing-client.tsx so the shown percent matches the displayed prices.
♻️ Duplicate comments (1)
nextjs/seat-based-billing/README.md (1)

117-139: Add a language to the fenced code block (MD040).
This duplicates a prior review note for the Project Structure block.

🔧 Suggested change
-```
+```text
 src/
 ├── app/
 │   ├── api/              # API routes
 │   │   ├── auth/         # BetterAuth routes
 │   │   ├── flowglad/     # Flowglad billing routes
 │   │   └── health/       # Health check endpoint
 │   ├── create-org/       # Organization creation page
 │   ├── pricing/          # Pricing page with tier selection
 │   ├── sign-in/          # Sign in page
 │   └── sign-up/          # Sign up page
 ├── components/
 │   ├── ui/               # shadcn/ui components
 │   └── navbar.tsx        # Navigation component
 ├── lib/
 │   ├── auth.ts           # BetterAuth server config
 │   ├── auth-client.ts    # BetterAuth client config
 │   ├── billing-helpers.ts # Pricing tier helpers
 │   └── flowglad.ts       # Flowglad server config
 └── server/db/
     ├── client.ts         # Drizzle client
     └── schema.ts         # Database schema
</details>

</blockquote></details>

</blockquote></details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Comment on lines +111 to +118
// Calculate yearly savings
const yearlySavings = isYearly
? Math.round(
((tier.monthlyPrice * 12 - tier.yearlyPrice) /
(tier.monthlyPrice * 12)) *
100
)
: 0
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard yearly savings math against zero/absent monthly price.
Line 112 can yield NaN when tier.monthlyPrice is 0 (e.g., yearly-only tiers).

🛠️ Suggested fix
-  const yearlySavings = isYearly
-    ? Math.round(
-        ((tier.monthlyPrice * 12 - tier.yearlyPrice) /
-          (tier.monthlyPrice * 12)) *
-          100
-      )
-    : 0
+  const yearlySavings =
+    isYearly && tier.monthlyPrice > 0
+      ? Math.round(
+          ((tier.monthlyPrice * 12 - tier.yearlyPrice) /
+            (tier.monthlyPrice * 12)) *
+            100
+        )
+      : 0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Calculate yearly savings
const yearlySavings = isYearly
? Math.round(
((tier.monthlyPrice * 12 - tier.yearlyPrice) /
(tier.monthlyPrice * 12)) *
100
)
: 0
// Calculate yearly savings
const yearlySavings =
isYearly && tier.monthlyPrice > 0
? Math.round(
((tier.monthlyPrice * 12 - tier.yearlyPrice) /
(tier.monthlyPrice * 12)) *
100
)
: 0
🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/src/app/pricing/pricing-client.tsx` around lines
111 - 118, The yearlySavings calculation can produce NaN when tier.monthlyPrice
is zero or missing; update the logic in the pricing-client.tsx where
yearlySavings is computed (using isYearly, tier.monthlyPrice, tier.yearlyPrice)
to first check that tier.monthlyPrice is a positive number before performing the
Math.round division, and return 0 (or an appropriate fallback) when monthlyPrice
is 0/undefined to avoid division by zero.

Comment on lines +122 to +236
const handleSelect = () => {
if (!priceSlug) return
onSelect(priceSlug, quantity)
}

return (
<Card
className={cn(
'relative flex h-full flex-col transition-all hover:shadow-lg',
tier.isPopular && 'border-primary ring-1 ring-primary',
isCurrentPlan && 'border-2 border-green-500'
)}
>
{tier.isPopular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<Badge className="bg-primary text-primary-foreground px-3 py-1">
<Sparkles className="h-3 w-3 mr-1" />
Most Popular
</Badge>
</div>
)}

<CardHeader className="pb-4 pt-6">
<CardTitle className="text-xl">{tier.name}</CardTitle>
<CardDescription className="text-sm min-h-[40px]">
{tier.isEnterprise
? 'For large teams with advanced needs'
: tier.name === 'Basic'
? 'For small teams getting started'
: 'For growing teams that need more'}
</CardDescription>

<div className="mt-4">
{tier.isEnterprise ? (
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold">Custom</span>
</div>
) : (
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold">${displayPrice}</span>
<span className="text-muted-foreground text-sm">
per user{period}
</span>
</div>
)}
{isYearly && yearlySavings > 0 && !tier.isEnterprise && (
<p className="text-xs text-green-600 mt-1">
Save {yearlySavings}% with yearly billing
</p>
)}
</div>

{/* Quantity selector for non-enterprise */}
{!tier.isEnterprise && !isCurrentPlan && (
<div className="mt-4 flex items-center gap-3">
<span className="text-sm text-muted-foreground">Users:</span>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => setQuantity(Math.max(1, quantity - 1))}
disabled={quantity <= 1}
>
-
</Button>
<span className="w-8 text-center font-medium">{quantity}</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => setQuantity(Math.min(100, quantity + 1))}
disabled={quantity >= 100}
>
+
</Button>
</div>
{quantity > 1 && (
<span className="text-sm text-muted-foreground">
= ${(displayPrice * quantity).toLocaleString()}
{period}
</span>
)}
</div>
)}
</CardHeader>

<CardContent className="flex-1 pt-0">
<ul className="space-y-2.5">
{features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="h-4 w-4 mt-0.5 shrink-0 text-primary" />
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
</CardContent>

<CardFooter className="pt-4">
<Button
className="w-full"
variant={tier.isPopular ? 'default' : 'outline'}
onClick={handleSelect}
disabled={isLoading || isCurrentPlan || !priceSlug}
>
{isLoading
? 'Loading...'
: isCurrentPlan
? 'Current Plan'
: tier.isEnterprise
? 'Contact Sales'
: 'Get Started'}
</Button>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

“Contact Sales” CTA currently initiates paid checkout.
Line 226 still triggers onSelect for enterprise if a priceSlug exists, which conflicts with the CTA intent.

🛠️ Suggested fix (block checkout for Enterprise until a sales flow is wired)
-  const handleSelect = () => {
-    if (!priceSlug) return
-    onSelect(priceSlug, quantity)
-  }
+  const handleSelect = () => {
+    if (!priceSlug || tier.isEnterprise) return
+    onSelect(priceSlug, quantity)
+  }
...
-          disabled={isLoading || isCurrentPlan || !priceSlug}
+          disabled={isLoading || isCurrentPlan || !priceSlug || tier.isEnterprise}

If you want a real sales flow, consider adding an onContactSales prop or a link target for enterprise.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleSelect = () => {
if (!priceSlug) return
onSelect(priceSlug, quantity)
}
return (
<Card
className={cn(
'relative flex h-full flex-col transition-all hover:shadow-lg',
tier.isPopular && 'border-primary ring-1 ring-primary',
isCurrentPlan && 'border-2 border-green-500'
)}
>
{tier.isPopular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<Badge className="bg-primary text-primary-foreground px-3 py-1">
<Sparkles className="h-3 w-3 mr-1" />
Most Popular
</Badge>
</div>
)}
<CardHeader className="pb-4 pt-6">
<CardTitle className="text-xl">{tier.name}</CardTitle>
<CardDescription className="text-sm min-h-[40px]">
{tier.isEnterprise
? 'For large teams with advanced needs'
: tier.name === 'Basic'
? 'For small teams getting started'
: 'For growing teams that need more'}
</CardDescription>
<div className="mt-4">
{tier.isEnterprise ? (
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold">Custom</span>
</div>
) : (
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold">${displayPrice}</span>
<span className="text-muted-foreground text-sm">
per user{period}
</span>
</div>
)}
{isYearly && yearlySavings > 0 && !tier.isEnterprise && (
<p className="text-xs text-green-600 mt-1">
Save {yearlySavings}% with yearly billing
</p>
)}
</div>
{/* Quantity selector for non-enterprise */}
{!tier.isEnterprise && !isCurrentPlan && (
<div className="mt-4 flex items-center gap-3">
<span className="text-sm text-muted-foreground">Users:</span>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => setQuantity(Math.max(1, quantity - 1))}
disabled={quantity <= 1}
>
-
</Button>
<span className="w-8 text-center font-medium">{quantity}</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => setQuantity(Math.min(100, quantity + 1))}
disabled={quantity >= 100}
>
+
</Button>
</div>
{quantity > 1 && (
<span className="text-sm text-muted-foreground">
= ${(displayPrice * quantity).toLocaleString()}
{period}
</span>
)}
</div>
)}
</CardHeader>
<CardContent className="flex-1 pt-0">
<ul className="space-y-2.5">
{features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="h-4 w-4 mt-0.5 shrink-0 text-primary" />
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter className="pt-4">
<Button
className="w-full"
variant={tier.isPopular ? 'default' : 'outline'}
onClick={handleSelect}
disabled={isLoading || isCurrentPlan || !priceSlug}
>
{isLoading
? 'Loading...'
: isCurrentPlan
? 'Current Plan'
: tier.isEnterprise
? 'Contact Sales'
: 'Get Started'}
</Button>
const handleSelect = () => {
if (!priceSlug || tier.isEnterprise) return
onSelect(priceSlug, quantity)
}
return (
<Card
className={cn(
'relative flex h-full flex-col transition-all hover:shadow-lg',
tier.isPopular && 'border-primary ring-1 ring-primary',
isCurrentPlan && 'border-2 border-green-500'
)}
>
{tier.isPopular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<Badge className="bg-primary text-primary-foreground px-3 py-1">
<Sparkles className="h-3 w-3 mr-1" />
Most Popular
</Badge>
</div>
)}
<CardHeader className="pb-4 pt-6">
<CardTitle className="text-xl">{tier.name}</CardTitle>
<CardDescription className="text-sm min-h-[40px]">
{tier.isEnterprise
? 'For large teams with advanced needs'
: tier.name === 'Basic'
? 'For small teams getting started'
: 'For growing teams that need more'}
</CardDescription>
<div className="mt-4">
{tier.isEnterprise ? (
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold">Custom</span>
</div>
) : (
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold">${displayPrice}</span>
<span className="text-muted-foreground text-sm">
per user{period}
</span>
</div>
)}
{isYearly && yearlySavings > 0 && !tier.isEnterprise && (
<p className="text-xs text-green-600 mt-1">
Save {yearlySavings}% with yearly billing
</p>
)}
</div>
{/* Quantity selector for non-enterprise */}
{!tier.isEnterprise && !isCurrentPlan && (
<div className="mt-4 flex items-center gap-3">
<span className="text-sm text-muted-foreground">Users:</span>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => setQuantity(Math.max(1, quantity - 1))}
disabled={quantity <= 1}
>
-
</Button>
<span className="w-8 text-center font-medium">{quantity}</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => setQuantity(Math.min(100, quantity + 1))}
disabled={quantity >= 100}
>
</Button>
</div>
{quantity > 1 && (
<span className="text-sm text-muted-foreground">
= ${(displayPrice * quantity).toLocaleString()}
{period}
</span>
)}
</div>
)}
</CardHeader>
<CardContent className="flex-1 pt-0">
<ul className="space-y-2.5">
{features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="h-4 w-4 mt-0.5 shrink-0 text-primary" />
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter className="pt-4">
<Button
className="w-full"
variant={tier.isPopular ? 'default' : 'outline'}
onClick={handleSelect}
disabled={isLoading || isCurrentPlan || !priceSlug || tier.isEnterprise}
>
{isLoading
? 'Loading...'
: isCurrentPlan
? 'Current Plan'
: tier.isEnterprise
? 'Contact Sales'
: 'Get Started'}
</Button>
🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/src/app/pricing/pricing-client.tsx` around lines
122 - 236, The "Contact Sales" button still triggers paid checkout because
handleSelect calls onSelect whenever priceSlug exists; update the logic so
enterprise tiers do not call onSelect: either change handleSelect to return
early when tier.isEnterprise, or switch the Button onClick to conditionally call
onSelect only for non-enterprise and call a new onContactSales prop (or navigate
to a contact URL) for enterprise; reference the handleSelect function, the
Button with onClick={handleSelect}, tier.isEnterprise, and the onSelect prop
when implementing this change.

Comment on lines +327 to +349
{/* Billing Toggle */}
<div className="flex items-center justify-center gap-4 mb-12">
<span
className={cn(
'text-sm font-medium',
!isYearly && 'text-foreground',
isYearly && 'text-muted-foreground'
)}
>
Monthly
</span>
<Switch checked={isYearly} onCheckedChange={setIsYearly} />
<span
className={cn(
'text-sm font-medium',
isYearly && 'text-foreground',
!isYearly && 'text-muted-foreground'
)}
>
Yearly
<Badge variant="secondary" className="ml-2 text-xs">
Save up to 17%
</Badge>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Static “Save up to 17%” badge can be misleading.
Line 347 shows a fixed value even though current tier prices imply 0% savings.

🛠️ Suggested fix (derive from pricing data)
 export function PricingClient() {
   const billing = useBilling()
   const [isYearly, setIsYearly] = useState(false)
   const [isCheckoutLoading, setIsCheckoutLoading] = useState(false)
   const [error, setError] = useState<string | null>(null)

   const tiers = groupProductsByTier(billing.pricingModel)
   const freePlan = getFreePlan(billing.pricingModel)
+  const maxSavings = Math.max(
+    0,
+    ...tiers
+      .filter((tier) => tier.monthlyPrice > 0 && tier.yearlyPrice > 0)
+      .map((tier) =>
+        Math.round(
+          ((tier.monthlyPrice * 12 - tier.yearlyPrice) /
+            (tier.monthlyPrice * 12)) *
+            100
+        )
+      )
+  )
...
           <span
             className={cn(
               'text-sm font-medium',
               isYearly && 'text-foreground',
               !isYearly && 'text-muted-foreground'
             )}
           >
             Yearly
-            <Badge variant="secondary" className="ml-2 text-xs">
-              Save up to 17%
-            </Badge>
+            {maxSavings > 0 && (
+              <Badge variant="secondary" className="ml-2 text-xs">
+                Save up to {maxSavings}%
+              </Badge>
+            )}
           </span>
🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/src/app/pricing/pricing-client.tsx` around lines
327 - 349, The static "Save up to 17%" badge is misleading; compute the actual
savings from the pricing data for the currently selected tier and render the
badge only when savings > 0. Use the selected plan/tier price fields (e.g.,
monthlyPrice and yearlyPrice or the existing tiers/selectedPlan variables) to
calculate percentSaved = Math.round((1 - (yearlyPrice/12) / monthlyPrice) * 100)
and replace the hardcoded text with this computed value; if percentSaved <= 0,
omit the <Badge> entirely. Ensure the logic is tied to isYearly toggling and the
same selected tier used elsewhere in pricing-client.tsx so the shown percent
matches the displayed prices.

Replaced hardcoded sample issues with working issue functionality:
- Create issues with a simple input field
- Toggle issues complete/incomplete by clicking the status icon
- Delete issues with hover-to-reveal trash button
- Shows real-time count of open vs completed issues
- Removed mock quick stats cards

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@nextjs/seat-based-billing/src/app/home-client.tsx`:
- Around line 292-308: Several icon-only Buttons lack accessible labels and the
issue toggle doesn't expose its pressed state; update each icon-only Button
(e.g., the create issue Button that calls handleCreateIssue and other Buttons
that call claimSeat/releaseSeat) to include a clear aria-label describing its
action, and for the issue toggle Button (the one that reads issue.done and
triggers toggleIssue) add aria-pressed={issue.done} plus a dynamic aria-label
that reflects the current state (e.g., "Mark issue as done" vs "Mark issue as
not done"); apply the same aria-label addition to every icon-only Button
instances referenced (create issue Plus Button, claim/release seat Buttons, and
other icon-only controls) so screen readers can announce their purpose.
♻️ Duplicate comments (1)
nextjs/seat-based-billing/src/app/home-client.tsx (1)

85-91: Initialize newQuantity when capacity is 0.

The truthy guard skips initialization for capacity = 0, leaving newQuantity at 1. This can create invalid state for empty plans.

🐛 Proposed fix
 useEffect(() => {
-  if (seatUsage?.capacity && !hasInitializedQuantity.current) {
+  if (seatUsage?.capacity != null && !hasInitializedQuantity.current) {
     setNewQuantity(seatUsage.capacity)
     hasInitializedQuantity.current = true
   }
 }, [seatUsage?.capacity])
🧹 Nitpick comments (1)
nextjs/seat-based-billing/src/app/home-client.tsx (1)

124-147: Use functional updates for issue state mutations.

These handlers rely on closed-over issues, which can drop updates if actions happen rapidly. Prefer functional updaters for concurrency safety.

♻️ Suggested refactor
-  setIssues([newIssue, ...issues])
+  setIssues((prev) => [newIssue, ...prev])

-  setIssues(
-    issues.map((issue) =>
-      issue.id === id ? { ...issue, done: !issue.done } : issue
-    )
-  )
+  setIssues((prev) =>
+    prev.map((issue) =>
+      issue.id === id ? { ...issue, done: !issue.done } : issue
+    )
+  )

-  setIssues(issues.filter((issue) => issue.id !== id))
+  setIssues((prev) => prev.filter((issue) => issue.id !== id))

Comment on lines +292 to +308
<div className="flex gap-2 mb-4">
<Input
placeholder="What needs to be done?"
value={newIssueTitle}
onChange={(e) => setNewIssueTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateIssue()
}}
className="text-sm"
/>
<Button
onClick={handleCreateIssue}
disabled={!newIssueTitle.trim()}
size="sm"
>
<Plus className="h-4 w-4" />
</Button>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and its size
ls -la nextjs/seat-based-billing/src/app/home-client.tsx 2>&1 | head -20 && \
wc -l nextjs/seat-based-billing/src/app/home-client.tsx

Repository: flowglad/examples

Length of output: 214


🏁 Script executed:

# Read the specified line ranges to verify the accessibility issues
echo "=== Lines 292-308 ===" && sed -n '292,308p' nextjs/seat-based-billing/src/app/home-client.tsx && \
echo "" && echo "=== Lines 324-347 ===" && sed -n '324,347p' nextjs/seat-based-billing/src/app/home-client.tsx && \
echo "" && echo "=== Lines 426-438 ===" && sed -n '426,438p' nextjs/seat-based-billing/src/app/home-client.tsx && \
echo "" && echo "=== Lines 460-474 ===" && sed -n '460,474p' nextjs/seat-based-billing/src/app/home-client.tsx && \
echo "" && echo "=== Lines 493-520 ===" && sed -n '493,520p' nextjs/seat-based-billing/src/app/home-client.tsx

Repository: flowglad/examples

Length of output: 4514


🏁 Script executed:

# Check for any existing aria-label or aria-pressed attributes in the file
echo "=== Checking for existing aria-label ===" && \
rg "aria-label" nextjs/seat-based-billing/src/app/home-client.tsx && \
echo "" && echo "=== Checking for existing aria-pressed ===" && \
rg "aria-pressed" nextjs/seat-based-billing/src/app/home-client.tsx

Repository: flowglad/examples

Length of output: 101


Add accessible labels to icon-only buttons and exposed state for toggles.

Icon-only buttons throughout this component lack aria-label attributes, making them inaccessible to screen reader users. The toggle button at line 324-330 additionally lacks aria-pressed to expose its state. This prevents keyboard and screen-reader users from completing critical tasks (creating issues, claiming/releasing seats, toggling issue status).

Fix all icon-only buttons by adding aria-label and update the toggle button to include both aria-pressed={issue.done} and a dynamic aria-label. This applies to all instances at lines 292-308, 324-347, 426-438, 460-474, and 493-520.

🤖 Prompt for AI Agents
In `@nextjs/seat-based-billing/src/app/home-client.tsx` around lines 292 - 308,
Several icon-only Buttons lack accessible labels and the issue toggle doesn't
expose its pressed state; update each icon-only Button (e.g., the create issue
Button that calls handleCreateIssue and other Buttons that call
claimSeat/releaseSeat) to include a clear aria-label describing its action, and
for the issue toggle Button (the one that reads issue.done and triggers
toggleIssue) add aria-pressed={issue.done} plus a dynamic aria-label that
reflects the current state (e.g., "Mark issue as done" vs "Mark issue as not
done"); apply the same aria-label addition to every icon-only Button instances
referenced (create issue Plus Button, claim/release seat Buttons, and other
icon-only controls) so screen readers can announce their purpose.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the amount calculator gets squished on full screen

Image

<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-xs font-medium text-primary">
{(claimItem.externalId ?? 'A')[0].toUpperCase()}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is throwing a type error for me, although it looks like it makes sense...maybe add optional chain

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason for using this separate route when the middleware looks like it's already setup?

</span>
</div>
<div>
<p className="text-sm font-medium">
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name gets squished

Image

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe remove these since they aren't being used by this example proj

@@ -0,0 +1,80 @@
'use client'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe update to match the new dashboard? still looks like the one from the image generation examples

})

export const metadata: Metadata = {
title: 'gen-based subscription example',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change meta title

</p>
)}

<Button
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this isn't working for me for some reason or i'm expecting the wrong behavior

Screen.Recording.2026-01-26.at.11.17.58.AM.mov

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this staying in?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this staying in?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants