Subscriptions

Sell recurring plans, such as a yearly membership, through Stripe, and check which plan a user holds.

Subscriptions lets your app sell recurring plans, such as a yearly membership, through Stripe. You declare the plans in config. The module handles checkout, upgrades, downgrades and the Stripe billing portal. Your code then asks one question: does this user hold plan X? For a one-time charge, use Payments.

The package is @frigear-nu/subscriptions. Stripe owns the products and prices. A plan is not a role: access follows what the user pays for, and "membership" is only the name the pages use. To give a role to paying users only, put the subscription requirement on it (see Permissions).

What you get

  • Pages (opt-in): a membership page at /account/membership, and a success and a cancel page after checkout.
  • Admin area (with Admin and Widget): a Membership tab where staff grant a free membership or send a payment link.
  • Server routes (on by default): plans with live prices, the user's subscription, checkout and the billing portal. Staff routes need users:read from Users, and subscriptions:manage to change anything.
  • Composables and helpers (on by default): useSubscription(), usePlans() and a subscription route middleware in the app, and hasActiveSubscription on the server.
ServerClientMobile
Installed for you:@frigear-nu/module-kit, Auth, Users, Stripe, Notify, MCP and UI.You add:@nuxthub/core with key-value storage (KV) on, and @nuxt/ui for the pages and components.Optional:Menu for the account menu item, Admin and Widget for the admin tab and card, Payments to store invoices as payments, Email Templates for the payment link email, and @nuxtjs/i18n for translated labels.

Quick setup

Install Subscriptions

pnpm add @frigear-nu/subscriptions @nuxthub/core @nuxt/ui

Declare your plans in nuxt.config.ts

nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    '@nuxthub/core',
    '@nuxt/ui',
    '@frigear-nu/menu', // optional
    '@frigear-nu/subscriptions', // installs Users and Stripe for you
  ],
  hub: { db: { dialect: 'sqlite', driver: 'libsql' }, kv: true },
  subscriptions: {
    plans: {
      member: { label: 'Member', rank: 1, prices: [{ lookupKey: 'member_yearly' }] },
      patron: { label: 'Patron', rank: 2, prices: [{ lookupKey: 'patron_yearly' }] },
    },
    settings: { pages: { membership: true, success: true, cancel: true } },
  },
})
Use test keys (sk_test_...) until the whole flow works: live keys charge real cards.

Connect your Stripe account

Follow Stripe setup for the keys, the webhook events, recurring prices with these lookup keys and the billing portal.

Without a migration, webhooks and checkouts fail.

Generate the migration

Generate one migration for the subscriptions table and Stripe's users column, and commit it:

pnpm db:generate

Check the plan in your code

Let only members call a route:

server/api/perks.get.ts
import { forbidden } from '@frigear-nu/module-kit/server'

export default defineEventHandler(async (event) => {
  const user = await requireUser(event)
  if (!await hasActiveSubscription(user.id, { plans: ['member', 'patron'] })) {
    throw forbidden('Members only.')
  }
  return { perks: [] }
})
What you should see: signed in, /account/membership lists both plans. After subscribing with Stripe's test card 4242 4242 4242 4242, GET /api/subscriptions/me returns activePlan: 'member' once the webhook has arrived.

Options

Options go under the subscriptions key.

OptionTypeDefaultPurpose
plansRecord<string, PlanConfig>{}Plans by plan key. Empty means any active subscription counts.
plans.<key>.pricesArray<string | { lookupKey }>–Required. Stripe price ids or lookup keys, such as monthly and yearly.
plans.<key>.ranknumber–Required. A higher rank is a higher tier, which decides upgrade or downgrade.
plans.<key>.labelstringthe keyThe display label.
plans.<key>.descriptionstring–The plan description.
plans.<key>.featuresstring[]–Bullet points on the plan card.
plans.<key>.availability.disabledRanges{ from, to }[]–Date windows in which new subscribers cannot see or buy the plan.
settings.pages.membershipstring | booleanofftrue is /account/membership. A string is a custom path.
settings.pages.successstring | booleanofftrue is /account/membership/success.
settings.pages.cancelstring | booleanofftrue is /account/membership/cancel.
settings.allowChangeInPortalbooleanfalseTells the interface that plans change in the billing portal. Not enforced.
settings.mcpbooleanfalseAdds the my-subscriptions MCP tool.

plans left empty makes checkout require a priceId. It then accepts any active recurring Stripe price, and stored rows get planKey = null.

plans.<key>.label, description and features give way to subscriptions.catalog.<key>.* i18n keys.

plans.<key>.availability.disabledRanges dates are inclusive. {fullYear} becomes the current year, as in {fullYear}-06-01. Subscribers still see the plan.

settings.pages.* need @nuxt/ui.

Runtime config

public.subscriptions exposes the page paths, the plans without prices and allowChangeInPortal.

Permissions

The module registers these permissions with Authorization.

PermissionFlagsHeld by defaultWhat it allows
subscriptions:readimplicitevery signed-in userRead your own subscriptions and the plans.
subscriptions:managesessionOnlynobodyGrant and revoke free memberships, and send payment links.
  • Nobody holds subscriptions:manage by default. Granting a free membership gives away paid access, so you grant it by name to the roles that may. * does not cover it for a token, because it is session only.
  • Staff reads of a user's membership use users:read, like the rest of the admin user page.
  • Requirement: the module registers the subscription role requirement. A role with requirements: { subscription: '*' } counts only while the user holds an active or trialing subscription on any plan (free grants count). Use a plan key, or a list of keys, to name plans.
  • Scope and policies: the module registers no named scope, role or policy. A token can carry the subscriptions:read permission directly.
nuxt.config.ts
export default defineNuxtConfig({
  authorization: {
    defaultRoles: ['user'],
    roles: {
      member: {
        label: 'Member',
        grant: ['member-area:view'],
        requirements: { subscription: ['member', 'patron'] }, // plan keys, or '*' for any plan
        auto: true, // held by everyone who meets the requirement; not assignable by hand
        inherit: ['user'],
      },
      coordinator: { label: 'Coordinator', grant: ['subscriptions:manage', 'users:read', 'users:list'] },
    },
  },
})
A role with auto: true cannot be assigned or removed by hand, and it drops away as soon as the requirement stops holding, for example when a subscription ends.

Module pages

PageWhat it coversFor
Server APIChecking plans, webhooks, checkout rules and staff membership.App developers and module authors
AppComposables, the route middleware, components and the membership pages.App developers
ReferenceHooks, routes and DTOs, the database and translations.App developers

Next steps

Copyright © 2026