Subscriptions
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:readfrom Users, andsubscriptions:manageto change anything. - Composables and helpers (on by default):
useSubscription(),usePlans()and asubscriptionroute middleware in the app, andhasActiveSubscriptionon the server.
@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
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 } },
},
})
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.
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:
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: [] }
})
/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.
| Option | Type | Default | Purpose |
|---|---|---|---|
plans | Record<string, PlanConfig> | {} | Plans by plan key. Empty means any active subscription counts. |
plans.<key>.prices | Array<string | { lookupKey }> | – | Required. Stripe price ids or lookup keys, such as monthly and yearly. |
plans.<key>.rank | number | – | Required. A higher rank is a higher tier, which decides upgrade or downgrade. |
plans.<key>.label | string | the key | The display label. |
plans.<key>.description | string | – | The plan description. |
plans.<key>.features | string[] | – | 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.membership | string | boolean | off | true is /account/membership. A string is a custom path. |
settings.pages.success | string | boolean | off | true is /account/membership/success. |
settings.pages.cancel | string | boolean | off | true is /account/membership/cancel. |
settings.allowChangeInPortal | boolean | false | Tells the interface that plans change in the billing portal. Not enforced. |
settings.mcp | boolean | false | Adds 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.
| Permission | Flags | Held by default | What it allows |
|---|---|---|---|
subscriptions:read | implicit | every signed-in user | Read your own subscriptions and the plans. |
subscriptions:manage | sessionOnly | nobody | Grant and revoke free memberships, and send payment links. |
- Nobody holds
subscriptions:manageby 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
subscriptionrole requirement. A role withrequirements: { 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:readpermission directly.
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'] },
},
},
})
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
| Page | What it covers | For |
|---|---|---|
| Server API | Checking plans, webhooks, checkout rules and staff membership. | App developers and module authors |
| App | Composables, the route middleware, components and the membership pages. | App developers |
| Reference | Hooks, routes and DTOs, the database and translations. | App developers |
Next steps
- Stripe setup – create the prices and the billing portal your plans use.
- Subscriptions app – build your own pricing page with the composables.