SaaS kit
The official saas plugin (paid) turns a InsightSite site into a SaaS product: sign-up and sign-in,
a user dashboard and user management in the admin. It covers what SaaS boilerplates such as
ShipFast or FlareStarter offer, on top of the CMS, locales, SEO and shop you already have, so no
second project is needed. The plugin ships with the framework (plugins/saas).
node node_modules/nuxtcms/scripts/plugin.mjs add saas
pnpm build:cf && pnpm deploy
Included: user accounts, plans and billing, plan-gated features, credits, API keys and teams.
Features
| Feature | Details |
|---|---|
| Sign-up and sign-in | E-mail + password; passwordless e-mail link (one-time, valid 15 minutes, signs up unknown addresses); forgotten password; optional "verify the e-mail before signing in" |
| Social sign-in | Google, GitHub, WeChat Open Platform website apps (QR code); links to an existing account by e-mail only when the provider says the address is verified |
User dashboard /account | Profile, change / set the password, sign out everywhere, plan and credits; account:main and account:aside slots for the site or other plugins |
| Admin User accounts | Search, status filter, sign-in methods, mark verified, disable / enable, sign out everywhere, view as the user (logged; the dashboard shows a banner) |
| Security | Own cookie and signing key (a user token can never pass as an admin token); a password change, disabling or "sign out everywhere" ends every older session; one-time links are stored hashed and work once; per-IP and per-account rate limits plus the site captcha on sign-in, sign-up and e-mails; redirects stay on the site |
Settings
Under Admin → Plugins → SaaS kit → Configure: open sign-ups, password sign-in, e-mail link sign-in, required e-mail verification, the page after sign-in, and the social sign-in keys. E-mail links, verification and password resets need SMTP under Admin → E-mail settings (in local development without SMTP the link is printed in the dev server console).
Keys may come from the environment instead (leave the fields empty):
NUXT_SAAS_GOOGLE_CLIENT_ID, NUXT_SAAS_GOOGLE_CLIENT_SECRET, NUXT_SAAS_GITHUB_CLIENT_ID,
NUXT_SAAS_GITHUB_CLIENT_SECRET, NUXT_SAAS_WECHAT_APP_ID, NUXT_SAAS_WECHAT_APP_SECRET.
Callback URLs to register with the providers:
| Provider | Callback |
|---|---|
| Google (OAuth client ID, web application) | https://your-domain/api/saas/oauth/google/callback |
| GitHub (OAuth App) | https://your-domain/api/saas/oauth/github/callback |
| WeChat Open Platform (website app) | authorized callback domain your-domain |
Using it in a site
Protect a page with the saas-auth middleware and read the user with useSaasUser() (user data
loads in the browser only, so pages stay cacheable by the CDN):
<script setup lang="ts">
definePageMeta({ middleware: 'saas-auth' })
const { user } = useSaasUser()
</script>
In server routes:
export default defineEventHandler(async (event) => {
const user = await requireSaasUser(event) // 401 when signed out
return { plan: user.plan, credits: user.credits }
})
getSaasUser(event) returns null for visitors. Extend the dashboard:
// app.config.ts
export default defineAppConfig({
nuxtcms: {
slots: {
'account:main': { MyUsageCard: 10 },
'header:actions': { SaasAccountButton: false }, // hide the header sign-in button
},
},
})
With the shop enabled too, the header shows both the shop member and the SaaS user entry; hide one as above.
The tables {prefix}saas_users, saas_identities and saas_tokens are created on first use
(SQLite / D1, PostgreSQL, MySQL).
Plans and billing
Create plans under Admin → Plans: a slug (e.g. pro, stored as the user's plan), a level
(higher = better), monthly / yearly prices, credits per month, name / description / features
per locale, "most popular" and "for sale". free is built in and needs no plan.
| Provider | How it works |
|---|---|
| Stripe subscriptions | Create monthly / yearly recurring prices in Stripe and enter their IDs (price_...) on the plan. Users subscribe through Stripe Checkout and Stripe renews; on the billing page they open the Stripe customer portal to change the card, get invoices, switch or cancel. Webhook: /api/saas/webhooks/stripe, events customer.subscription.*, checkout.session.completed, invoice.paid |
| WeChat Pay / Alipay | Buy a month or a year (currency CNY): WeChat QR code on desktop (Native), H5 in mobile browsers (enable it on the merchant platform), Alipay desktop / mobile website pay. Renewing the same plan extends it from its current end; another plan starts on the day of payment. Callbacks: /api/saas/webhooks/wechat, /api/saas/webhooks/alipay |
Payment keys go under Admin → Plugins → SaaS kit → Configure or in the environment
(NUXT_SAAS_STRIPE_SECRET_KEY, NUXT_SAAS_WECHAT_PAY_MCH_ID, ...). Empty fields fall back
to the shop plugin's payment settings, so a site does not enter them twice (except the Stripe
webhook signing secret: each endpoint has its own). On demo sites (NUXT_DEMO_MODE=true)
WeChat Pay and Alipay are simulated.
Pages:
| Page | What it does |
|---|---|
/pricing | Pricing table (monthly / yearly switch, most popular, yearly saving). Put <SaasPricing /> on any page, or replace the page with the site's own pages/pricing.vue |
/account/billing | Plan and its end / renewal date, credits, payment history, "manage subscription & invoices" (Stripe portal), renew, upgrade |
/account/pay/<order> | WeChat QR code or Alipay redirect, polls the payment result |
| Admin Plans, Revenue | Plan management; revenue of the last 30 days, paying users, active subscriptions, orders; under User accounts set a plan, end date and credits by hand (gifts, bank transfers, refunds) |
Every payment emits the CMS event saas.payment.paid for the robot / webhook plugins.
Plan-gated features
Pages (guidance in the browser: visitors go to sign-in, lower plans to /pricing):
<script setup lang="ts">
definePageMeta({ saasPlan: 'pro' }) // Pro or a higher level; ['team', 'enterprise'] = only those
</script>
APIs (the real protection is on the server):
export default defineEventHandler(async (event) => {
const user = await requireSaasPlan(event, 'pro') // 401 signed out, 403 plan too low
return { ok: true, credits: user.credits }
})
To show or hide paid UI: GET /api/saas/billing/access?plan=pro answers { allowed, plan }, or
use useSaasUser().user.value.plan with saasPlanAllows().
Credits
Every user has a credit balance and a ledger (reason, related order or request, balance after each change). Plan payments add the plan's credits per month; admins add or remove credits by hand. Spend credits for metered features on the server:
export default defineEventHandler(async (event) => {
const user = await requireSaasApiUser(event)
const { balance } = await spendSaasCredits(event, user.id, 5, 'ai.summary', { ref: 'post-42' })
// not enough credits: 402 saas.credits.insufficient ({ needed, balance }), nothing is spent
return { ok: true, balance }
})
Spending is one conditional UPDATE, so concurrent requests never go below zero;
addSaasCredits(event, userId, n, reason) grants or refunds. Users see the ledger on the
billing page.
API keys
Users create keys at /account/api-keys (nck_..., shown once, only a hash is stored), name
them, set an expiry, see the last use and revoke them; up to 10 each. Requests send
Authorization: Bearer nck_... or X-API-Key.
const user = await requireSaasApiUser(event) // browser session or API key
const user = await requireSaasApiPlan(event, 'pro') // and on Pro or higher
Example endpoint: GET /api/saas/v1/me. The plugin settings switch keys off, set the lowest
plan for them (e.g. pro) and the requests per minute per key (default 120). Keys cannot be
created while an admin views as the user, and a key never opens the admin.
Teams
Plans have team seats (owner included; 1 = no team). A user whose plan has more than one seat
creates a team at /account/team and invites members by e-mail (valid 7 days; when the e-mail
cannot be sent the page shows the invitation link). The invitee signs in or signs up with that
address and accepts.
- Members share the owner's plan:
requireSaasPlan, thesaasPlanpage meta and/api/saas/billing/accessuse the highest level of the user's own plan and their teams'. Credits and API keys stay personal. - Roles: owner (deletes the team, makes admins), admin (invites, removes members), member (may leave).
- When the owner's plan ends or drops, members lose it too; with fewer seats the earliest members keep it.
- Teams can be switched off in the plugin settings.
Lifecycle e-mails
With "Automatic e-mails" on in the plugin settings (off by default) a scheduled task checks hourly and sends:
- Welcome once, within 3 days of sign-up;
- Plan ending reminders some days before a paid term ends ("Reminder days", default
7,1); not for Stripe subscriptions that renew by themselves; - Plan ended once, inviting the user back.
Every e-mail is claimed in saas_mail_log before it is sent, so parallel runs never send twice;
a failed send is retried on the next run. It needs e-mail (SMTP) and the cron worker
(NUXT_CRON_SECRET); the endpoint is POST /api/cron/saas/lifecycle.
Testing
pnpm smoke:saas --base http://localhost:3192 --db .data/saas-demo.sqlite runs the whole flow
against a dev server (launch config nuxtcms-dev-saas): sign-up, sign-in, password change,
e-mail links, reset, verification, settings switches, admin actions and forged tokens, plus plans, WeChat Pay / Alipay callbacks (forged ones too),
renewals and upgrades, Stripe webhooks, admin plan changes, the credit ledger, API keys (rate
limit, plan rule, revoking) and teams (invitations, seats, roles, shared plan). It changes
that database: never point it at a real site.