Next.js SaaS Starter: auth, teams, Stripe billing and SEO, tested and ready
by Syncoders
$69
Skip the two weeks of plumbing every SaaS needs before its first real feature.
Auth: email magic links (hashed, single-use, 15-minute expiry, rate-limited) plus GitHub and Google OAuth with CSRF-safe state (bring your own free OAuth app; each button appears once its keys are set). DB-backed sessions.
Teams and roles: a personal team for every user, email invites with expiry, owner/admin/member roles, a team switcher and the "can't remove the last owner" rule.
Billing per team (Stripe test mode ready): Stripe subscriptions, one-time payments, Customer Portal and seat limits per plan, a stripe:setup script for test prices, using the same production webhook handler as RetrySafe Billing (signature check, idempotency, retries, out-of-order protection). Only owners can change billing.
Clean UI with Tailwind and shadcn-style components: sidebar app shell, dashboard with seat usage and a getting-started list, settings, team and billing pages, dark and light mode, lucide icons, empty states.
Ready for AI coding tools: AGENTS.md, CLAUDE.md and Cursor rules spell out the permission and team-scoping rules, so generated code stays secure.
Email: 5 templates (magic link, welcome, invite, payment failed, receipt) in HTML and plain text, all wired up: sign-in, welcome and invite emails, plus receipt and payment-failed emails sent to team owners from the Stripe webhook. Console transport for dev (links print in your terminal), Resend for production (your own API key), plus a preview script.
Landing page and SEO: hero, features, pricing, canonical and Open Graph tags, an auto-generated OG image, sitemap.xml, robots.txt and JSON-LD.
60 tests (Vitest) covering roles, magic-link tokens (including a double-click race and the 15-minute expiry), OAuth (mocked provider), rate limiting, safe post-login redirects, webhooks (including crash recovery, lease fencing and concurrent retries), seat limits, billing emails and SEO.
Unzip it, run npm install && cp .env.example .env && npx prisma db push && npm run seed && npm run dev, and sign in as owner@example.com. The magic link prints in your terminal.
Stack: Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS, Prisma 5 (SQLite; Postgres by switching the provider and DATABASE_URL), Stripe 17, Resend (HTTP API), Vitest. Node 20.19+ (or 22.12+).
What you get: a ZIP of the full source code. Unlimited projects for you or your clients (no resale of the kit itself). Lifetime v1.x updates.
FAQ
Do I need keys to try it? No. Auth, teams and emails work locally. Add your free Stripe test key for real checkout; OAuth needs your own GitHub/Google app.
Postgres? Change the provider in prisma/schema.prisma and set a postgres:// DATABASE_URL.
Refunds? 14 days.
Free sample
True excerpt: lib/rbac.ts (team roles) from v1.0.3. The full kit adds auth, teams, per-team Stripe billing, SEO, Tailwind UI and 60 tests.
export type Role = "owner" | "admin" | "member";
const RANK: Record<Role, number> = { member: 0, admin: 1, owner: 2 };
export type Action =
| "team:view" | "team:update" | "team:delete"
| "members:invite" | "members:remove" | "members:changeRole"
| "billing:view" | "billing:manage";
const MIN_ROLE: Record<Action, Role> = {
"team:view": "member",
"team:update": "admin",
"members:invite": "admin",
"members:remove": "admin",
"members:changeRole": "admin",
"billing:view": "admin",
"billing:manage": "owner",
"team:delete": "owner",
};
export function can(role: Role, action: Action) { return RANK[role] >= RANK[MIN_ROLE[action]]; }
/**
* Role-change rules: admins can't touch owners or create owners; only owners can
* promote to owner; the last owner can't be demoted or removed (no orphaned teams).
*/
/** Why a role change is refused, as an error code for `/settings/team?error=` (see lib/team-errors.ts), or null if allowed. */
export function roleChangeError(actor: Role, target: Role, next: Role | null, ownerCount: number): "forbidden" | "owners-only" | "last-owner" | null {
if (!can(actor, "members:changeRole")) return "forbidden";
if (actor !== "owner" && (target === "owner" || next === "owner")) return "owners-only";
if (target === "owner" && next !== "owner" && ownerCount <= 1) return "last-owner";
return null;
}
const ROLE_CHANGE_MESSAGES = { forbidden: "You don't have permission to manage members.", "owners-only": "Only owners can manage owners.", "last-owner": "A team needs at least one owner." } as const;
export function canChangeRole(actor: Role, target: Role, next: Role | null, ownerCount: number): string | null {
const code = roleChangeError(actor, target, next, ownerCount);
return code && ROLE_CHANGE_MESSAGES[code];
}
export class ForbiddenError extends Error { status = 403; }
export function assertCan(role: Role, action: Action) { if (!can(role, action)) throw new ForbiddenError(`Requires permission ${action}`); }