saas-starter

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

2posts · 30d
Nov 40 posts
  1. Free vs Paid Next.js SaaS Boilerplates (2026): What You Actually Get --- title: "Free vs Paid Next.js SaaS Boilerplates (2026): What You Actually Get" published: true description: "Free vs paid Next.js SaaS boilerplates in 2026 — what nextjs/saas-starter, create-t3-app, and ixartz give you versus paid kits like ShipEmber, with a fair comparison table." tags: nextjs, saas, webdev, opensource canonical_url: shipember.com --- By Jake Mcdonald, founder of ShipEmber · November 2, 2026 · About an 11-minute read · Free kits checked against their GitHub repos today I'm Jake Mcdonald. I sell a paid Next.js starter (ShipEmber), so this page has a conflict of interest. I'm still going to say this clearly: free open-source starters are often the right first move. The question is what "free" actually includes — and what you still have to wire yourself before the first paid customer. I verified the free options below against their public repos or sites on November 2, 2026. Paid prices match each vendor's live page the same day (or our own pricing for ShipEmber). Free vs paid at a glance | Kit | Price | Auth | Stripe subscriptions | Best for | | --- | --- | --- | --- | --- | | nextjs/saas-starter | Free (MIT) | Email/password + JWT cookies | Checkout + Customer Portal (basic) | Learning the shape of a SaaS | | create-t3-app | Free (MIT) | Auth.js optional | No — bring your own | Typesafe scaffold, not a SaaS kit | | ixartz/SaaS-Boilerplate | Free (MIT) | Clerk | Not the focus of the public README | Multi-tenant + i18n learning | | ShipEmber | $49 launch week, then $79 | Auth.js v5 (Google, GitHub, magic links) | Checkout, portal, signed idempotent webhooks | Minimal solo Free→Pro MVP | | ShipFast / Makerkit / etc. | ~$157–$1,499 | Varies | Usually yes | Broader indie or B2B surface | What "free" actually means Free usually means MIT code you can clone. It does not mean: - OAuth providers configured for production, - webhook idempotency and plan gating you can trust under retries, - marketing pages ready to rebrand, - or someone answering Discord questions when Stripe returns a weird event. You still pay with time. Sometimes that trade is correct. nextjs/saas-starter (Vercel / Next.js) Verified: nextjs/saas-starter — MIT, ~16k stars when I checked today. Demo linked from the README: next-saas-start.vercel.app. From the README, you get: - Next.js + Postgres + Drizzle + Stripe + shadcn/ui - Marketing landing page and a pricing page wired to Stripe Checkout - Dashboard CRUD, basic Owner/Member RBAC - Stripe Customer Portal for subscription management - Email/password auth with JWTs in cookies - Middleware to protect logged-in routes; activity logging Honest take: This is the best free reference I know for "what a clean Next.js + Stripe + Postgres app looks like." Gaps vs a commercial kit: no Google/GitHub OAuth out of the box, no polished one-file rebrand, and you will harden webhook/idempotency patterns yourself for production. Start here if you're learning. Graduate when the missing pieces cost more evenings than a cheap paid kit. create-t3-app Verified: create.t3.gg and the t3-oss/create-t3-app repo (MIT). Install with npm create t3-app@latest. Their own copy is clear: it is not an all-inclusive SaaS template. It scaffolds the pieces you opt into — Next.js, TypeScript, Tailwind, Prisma (or others), tRPC, and Auth.js (NextAuth) — with strong opinions about type safety. No Stripe. No pricing page. No customer portal. Honest take: Perfect when you want a typesafe app shell and you intend to own billing. Terrible if you expected "clone and charge cards tomorrow." Pair it with Stripe docs (or a kit) when you need subscriptions. ixartz/SaaS-Boilerplate Verified: ixartz/SaaS-Boilerplate — MIT, demo at react-saas.com. README lists Next.js, Tailwind, shadcn/ui, Clerk auth, multi-tenancy with teams/roles, Drizzle, i18n, landing, dashboard, tests, and CI. Stripe is not the headline feature of the public README. Honest take: Useful if you want to study multi-tenant patterns and you're fine with Clerk (their free tier has limits; paid Clerk is a separate bill). If your requirement is "Auth.js + Stripe subscriptions on Postgres with no third-party auth vendor," this is the wrong free kit. When free is the better pick - You're learning App Router, Drizzle/Prisma, or Stripe for the first time. - You have a weekend and a high tolerance for wiring env vars and webhook secrets yourself. - Your product is not ready to charge anyone — scaffolding beats buying. - You specifically want create-t3-app's typesafe stack and will add billing later. Related reading on that tradeoff: topic #12 in our internal notes became this page. Also see Best Next.js SaaS boilerplates in 2026. When a paid kit is the better pick - You've already burned a week on auth + webhooks and just want the boring path paved. - You need OAuth (Google/GitHub) + magic links + Stripe Checkout + portal + server-side Free/Pro gating together. - You want a small, opinionated codebase with docs aimed at shipping, not at teaching every option. - Your hourly rate makes $79–$249 cheaper than two more evenings of billing bugs. Paid kits are not equal. ShipFast, Makerkit, and supastarter buy you more surface area (and more learning). ShipEmber buys you the core loop only. I wrote separate comparisons: ShipFast, Makerkit, supastarter, Shipped.club. Why choose ShipEmber (if you buy anything) Here's the honest pitch — same points I'd tell a friend. 1. Paid for the gap free kits leave ShipEmber is Auth.js v5 + Stripe subscriptions (Checkout, customer portal, signature-verified idempotent webhooks) + Prisma/Postgres + a gated dashboard + marketing pages + one-file rebrand. That's the gap between "I cloned saas-starter" and "I can charge Pro tonight." 2. Price stays near free-kit territory Live price: $49 launch week, then $79 — see pricing. One-time. Instant zip download. 14-day refund if it doesn't work as described. Lifetime updates to v1. 3. Intentionally small No orgs, no i18n, no five payment providers. If you need those, buy Makerkit or supastarter — or start from nextjs/saas-starter and grow into them. If you want the paid core loop Get the zip, add your keys, and build the product part. Get ShipEmber, $49 Pricing details · What's inside · Auth.js + Stripe checklist Related: Full boilerplate comparison · Stripe subscriptions deep dive · Why I built ShipEmber Verification note: nextjs/saas-starter and ixartz/SaaS-Boilerplate READMEs and licenses checked on GitHub on November 2, 2026. create-t3.gg checked the same day. Vendor pages and READMEs change. — Jake Mcdonald, Springfield, IL

  2. Block burner emails in the Next.js SaaS starter --- title: "Block burner emails in the Next.js SaaS starter" published: true description: "Add a SignupBouncer check to the starter's signup action before it hashes the password. Then switch to Enforce to refuse disposable addresses and limit repeat signups from the same inbox." tags: nextjs, webdev, security, tutorial canonical_url: signupbouncer.com cover: content/nextjs-saas-starter/cover/devto.png (1000x420), uploaded with the post --- I build SignupBouncer, the signup check in this guide. The original is on signupbouncer.com. Vercel's Next.js SaaS starter lets anyone sign up with any email address, including burner addresses from disposable email services. The same person can also sign up again and again by moving a dot or adding a +tag to a Gmail address. This guide adds a SignupBouncer check to signUp, the server action that creates the user, so your form refuses burner addresses and repeat signups. 1. Get the starter running on your machine. You'll run Postgres in Docker and write .env by hand. 2. Create a SignupBouncer API key and copy our helper file into lib/signupbouncer.ts. 3. Call checkSignup in signUp, before it hashes the password. 4. In production, set SIGNUPBOUNCER_IP_HEADER to the header that holds the client's IP. 5. Try a few signups in shadow mode and see how we marked them on your dashboard. Then switch your app to Enforce. 6. Lowercase the email in the starter's schemas, so an address typed with capitals matches the same account. The starter sends no confirmation email, so someone can even sign up with an address at a domain that can't receive mail. Every new account comes with a team, and every team can start the 14-day trial the starter sets up at Stripe Checkout. A refused person sees an error in the signup form you already have. Someone who keeps signing up with the same Gmail inbox gets through the first few times. After that, your form refuses them. The finished code is in signupbouncer/nextjs-saas-starter-signup-protection. We built it from the starter at commit 6e33e58 (11 Dec 2025) and moved it to Next.js 16.4.2. The finished code keeps the starter's MIT license. Get the starter running If the starter already runs on your machine, do the Next.js upgrade below and then skip ahead to getting a SignupBouncer key. If it doesn't, clone the starter and install its packages: git clone https://github.com/nextjs/saas-starter cd saas-starter pnpm install The starter pins a Next.js 15.6 canary, which you'll see under next in your package.json. That canary came out before the fix for CVE-2026-96889, a critical remote code execution bug in Next.js. Move the starter to Next.js 16.4.2: pnpm add next@16.4.2 Next.js 16 doesn't accept the starter's experimental ppr flag. In next.config.ts, delete the whole experimental block, which holds ppr and clientSegmentCache. That leaves nextConfig as an empty object. The starter's pnpm db:setup script writes .env for you, but it needs Docker and the Stripe CLI. The SignupBouncer check doesn't need Stripe, so you can write .env by hand. Start Postgres in Docker on port 54322, the port pnpm db:setup uses: docker run -d --name saas-starter-db -p 127.0.0.1:54322:5432 -e POSTGRES_PASSWORD=postgres postgres:16.4-alpine The 127.0.0.1: in the docker run command makes the database reachable from your own machine only. Without 127.0.0.1:, Docker opens port 54322 on every network interface. Anyone on your network could then log in with the password postgres. Then create .env: POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:54322/postgres STRIPE_SECRET_KEY=sk_test_placeholder STRIPE_WEBHOOK_SECRET=whsec_placeholder BASE_URL=http://localhost:3000 AUTH_SECRET=replace-with-a-long-random-string For AUTH_SECRET, any long random string works, and openssl rand -base64 32 makes one. Signup works with the placeholder Stripe values. You'll need your Stripe test keys for the pricing page, Checkout and pnpm build, because they call Stripe. The starter's README covers the Stripe setup. Create the tables, then start the app: pnpm db:migrate pnpm dev Don't run pnpm db:seed. It creates products in your Stripe account. The first time you run pnpm dev, Next.js rewrites a few lines of tsconfig.json, so git status shows that file as changed. pnpm dev also warns that middleware is deprecated. The warning comes from the upstream starter, and signup works with it. Get a SignupBouncer key Sign up for SignupBouncer. On the Free plan, you get 1,000 checks a month and you don't need a card. On your app's Integration tab, choose "Create API key". We show the key only once, so copy it right away. Keys start with sb_live_. Add the helper file The helper is the file lib/signupbouncer.ts, and it has no dependencies. Download it to the same path in your app: curl -o lib/signupbouncer.ts https://raw.githubusercontent.com/signupbouncer/nextjs-saas-starter-signup-protection/master/lib/signupbouncer.ts Add these lines to .env.example and to .env. In .env, replace sb_live_*** with the key you copied: # signupbouncer: your sb_live_... key. Server only, never NEXT_PUBLIC_. SIGNUPBOUNCER_API_KEY=sb_live_*** # The header your host overwrites with the client IP. On Vercel: x-real-ip. # Leave it unset and the email is checked alone. # SIGNUPBOUNCER_IP_HEADER=x-real-ip # Only for a local signupbouncer API. Leave unset in production. # SIGNUPBOUNCER_BASE_URL=http://127.0.0.1:8080 SIGNUPBOUNCER_API_KEY has no NEXT_PUBLIC_ prefix, so the key stays on the server. Leave SIGNUPBOUNCER_BASE_URL unset, because it's only for our own tests against a local copy of SignupBouncer. Call the check in signUp Open app/(login)/actions.ts. Replace the line that imports cookies from next/headers with these lines: import { cookies, headers } from 'next/headers'; import { blockMessage, checkSignup, clientIp, shouldBlock } from '@/lib/signupbouncer'; In signUp, find the existingUser check and the hashPassword line after it. Add the SignupBouncer check between them. In the code below, the existingUser check at the top and the hashPassword line at the bottom are already in your file: if (existingUser.length > 0) { return { error: 'Failed to create user. Please try again.', email, password }; } // Refuse burner, unreachable and repeat sign-ups before the user exists. // If signupbouncer is slow or down, the sign-up goes through. const h = await headers(); const check = await checkSignup( { apiKey: process.env.SIGNUPBOUNCER_API_KEY ?? '', baseUrl: process.env.SIGNUPBOUNCER_BASE_URL || undefined, onError: (e) => console.warn('signupbouncer:', e.reason, e.status ?? '') }, { email, ip: clientIp(h, process.env.SIGNUPBOUNCER_IP_HEADER), userAgent: h.get('user-agent') ?? undefined } ); if (shouldBlock(check)) { return { error: blockMessage(check), email, password }; } const passwordHash = await hashPassword(password); checkSignup sends us the email and the user agent. It sends the client IP too once you set SIGNUPBOUNCER_IP_HEADER. shouldBlock(check) returns true when our answer's action is "block". When shouldBlock returns true, signUp returns the block message in the starter's own { error } state. The signup form already displays error, so you don't need to edit the signup page. The check runs before hashPassword. So for a refused signup, the starter skips the password hashing and creates no user and no team. !The /sign-up page refusing a mailinator.com address with "Please sign up with a permanent email address." blockMessage(check) returns the block message you set on your app's Rules tab in the dashboard. If you keep the default message, the wording depends on the reason for the refusal. Someone signing up with a burner domain sees "Please sign up with a permanent email address." Someone who mistypes a big provider's domain sees "Did you mean ann@gmail.com?" If our answer has no message, blockMessage falls back to DEFAULT_BLOCK_MESSAGE, which is the burner-domain wording. To use your own words, change the message on the Rules tab, or return your own string instead of blockMessage(check). checkSignup never throws. It returns action: "allow" on a timeout after 1.5 seconds, a network error, an error status or an incomplete answer. It also calls onError, which logs a fixed reason such as signupbouncer: timeout. Your app refuses a signup only when it gets a complete answer from us. If SignupBouncer is slow or down, the signup goes through. An outage on our side delays a signup by 1.5 seconds at most. In our test run with the SignupBouncer API stopped, a new user still reached the starter's dashboard in about a tenth of a second. The client IP On your machine, the check runs without the client's IP. In production, set SIGNUPBOUNCER_IP_HEADER to the header your host overwrites with the client's IP. On Vercel the header is x-real-ip. Add SIGNUPBOUNCER_IP_HEADER=x-real-ip to your Vercel project's environment variables, next to SIGNUPBOUNCER_API_KEY. For Cloudflare and Fly.io, the comment above clientIp() in lib/signupbouncer.ts names the header to use. clientIp() reads only the header you name. Clients can send X-Forwarded-For or X-Real-IP themselves. If your host doesn't replace the header you name, the header holds whatever the client sent. On the Starter plan and above, we add points for datacenter, VPN and Tor addresses. A VPN or Tor address on its own never scores enough for a block. On the Free plan, we still run the IP checks, but their points don't change the score. The answer's shadow field lists the points a paid plan would add. Shadow mode first, then enforce Every SignupBouncer app starts in shadow mode. We check and log each signup, and action is always "allow". So shouldBlock returns false and every signup gets an account. Sign up a few times through your form, once with an address at mailinator.com. Test signups count toward your monthly checks. Open the Checks tab on your dashboard. The mailinator.com signup shows as block, and its account exists all the same. When the results look right, open your app's Rules tab. Choose Enforce next to Mode, then Turn on blocking in the window that opens. From then on, your form refuses any signup marked block. Lowercase the email The upstream starter stores an email exactly as it was typed, so Test@test.com and test@test.com become two accounts (nextjs/saas-starter#184). To fix it, add .trim().toLowerCase() after z.string() in every schema in app/(login)/actions.ts that reads an email. Start with signInSchema: const signInSchema = z.object({ email: z.string().trim().toLowerCase().email().min(3).max(255), password: z.string().min(8).max(100) }); Add .trim().toLowerCase() to signUpSchema too: const signUpSchema = z.object({ // One account per address, whatever its case (nextjs/saas-starter#184). email: z.string().trim().toLowerCase().email(), password: z.string().min(8), inviteId: z.string().optional() }); Change updateAccountSchema, which the account settings form uses: const updateAccountSchema = z.object({ name: z.string().min(1, 'Name is required').max(100), email: z.string().trim().toLowerCase().email('Invalid email address') }); Change inviteTeamMemberSchema, which the team invite form uses: const inviteTeamMemberSchema = z.object({ email: z.string().trim().toLowerCase().email('Invalid email address'), role: z.enum(['member', 'owner']) }); Invitations sent before this change can still have capitals in their stored address. In signUp, find the query that matches an invite link to its invitation, and change it to compare the stored address in lower case. The changed query uses sql, which the file already imports from drizzle-orm: const [invitation] = await db .select() .from(invitations) .where( and( eq(invitations.id, parseInt(inviteId)), // Invitations sent before the lowercase change can hold capitals. eq(sql`lower(${invitations.email})`, email), eq(invitations.status, 'pending') ) ) .limit(1); After the change, an invitation sent to Ann@gmail.com matches a signup as ann@gmail.com. Upstream's signUp creates the user before it looks up the invitation. So a signup through an expired invite link still leaves an account with no team. The schema change only lowercases new input. If your database already has emails with capitals, lowercase them once. The email column of users is unique, so first find accounts that differ only by case, and merge or rename them. Then lowercase the emails in users and invitations together: select lower(email), count(*) from users group by 1 having count(*) > 1; update users set email = lower(email) where email <> lower(email); update invitations set email = lower(email) where email <> lower(email); Repeat signups from the same inbox Gmail ignores dots in an address and anything after a +. So a.b@gmail.com, ab+1@gmail.com and a.b+2@gmail.com all reach the same inbox. On every plan, we treat those Gmail spellings as the same inbox. We count how many times the inbox signed up over the last 7 days, across every app on your SignupBouncer account. Each repeat adds points to the signup: 25 for the second, 40 for the third, 55 for the fourth, 70 for the fifth and 75 from the sixth on. The second signup stays below the challenge cutoff, because people do sign up again after they lose a password. With the default cutoffs, a score of 40 marks a signup challenge and a score of 70 marks it block. So we mark the third and fourth signups challenge. They still get accounts, because shouldBlock(check) returns false for a challenge. Your form refuses the fifth signup. In our test run, the same inbox got four accounts, and the form refused its fifth spelling. To refuse from the third signup on, change the condition to shouldBlock(check, 'block'). A challenge is any score between the two cutoffs. So shouldBlock(check, 'block') also refuses other signups marked challenge, such as an address on a free subdomain like ann@foo.duckdns.org (45 points). On the Pro plan, velocity rules add points when many signups arrive in a short time from the same network, or from the same inbox in several spellings. Velocity points can raise a signup to challenge but never to block. So your form refuses a signup over velocity points only when you refuse challenges. What the check doesn't stop A person with several real inboxes at different providers, say a Gmail and an Outlook address, gets an account with each. We can't link two unrelated inboxes to the same person, so each address starts its own repeat count. We still score each of those signups on its email address and its network. On the Starter plan and above, we add points to a signup from a datacenter, VPN or Tor address, whichever inbox the person uses. The check doesn't prove that the person owns the address. For that, add email verification to the starter. On the Free plan, once you've used 1,000 checks in a month, signups go through unchecked until the month ends. Run the finished code git clone https://github.com/signupbouncer/nextjs-saas-starter-signup-protection cd nextjs-saas-starter-signup-protection pnpm install cp .env.example .env Fill in .env the same way as in the first section, and set SIGNUPBOUNCER_API_KEY to your key. Then create the tables and start the app: pnpm db:migrate pnpm dev Open http://localhost:3000/sign-up and sign up with an address at mailinator.com. With your app set to Enforce, the form shows "Please sign up with a permanent email address." and the starter creates no account. The repo keeps the starter's own README as README.upstream.md, with the Stripe setup and the steps for going to production. Where to go next - signupbouncer/nextjs-saas-starter-signup-protection has the finished code and its tests. - The REST API reference lists every field in our answer, and the errors. - The reason codes table shows what each reason means and how the points add up. - Shadow and enforce shows what each mode does with a check's result.

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

No packages found.

Live mentions badge

Show how many posts mention your project in the last 30 days.

Live mentions · last 30 days
nextjs/saas-starter mentions in the last 30 days