DEV Community

Cover image for I Built a Production-Ready Node.js SaaS Boilerplate So You Don't Have To
joonlee22
joonlee22

Posted on

I Built a Production-Ready Node.js SaaS Boilerplate So You Don't Have To

Every time I started a new SaaS project, I spent the first 3 days building the same things.

Auth. Billing. Database setup. Deployment config. Every. Single. Time.

So I stopped. I packaged everything into a production-ready boilerplate and I'm never doing it again.

Here's exactly what I built and how it works.

The Stack

  • Backend: Node.js + Express
  • Frontend: Next.js 14 + Tailwind CSS
  • Database: PostgreSQL + Prisma ORM
  • Auth: JWT (access + refresh tokens) + Google OAuth
  • Billing: Stripe subscriptions
  • Deployment: Railway (backend) + Vercel (frontend)

Auth — Done Right

Most tutorials show you JWT auth but skip the details that matter in production:

  • Access tokens expire in 7 days
  • Refresh tokens are stored in the database and rotated on every use — if a token is stolen and used, the original is invalidated
  • Google OAuth creates or links accounts automatically
  • bcrypt with 12 salt rounds for password hashing
  • Rate limiting on auth routes (10 requests per 15 minutes) to block brute force attacks
// Refresh token rotation — prevents reuse after theft
const rotateRefreshToken = async (rawRefresh) => {
  const stored = await prisma.refreshToken.findUnique({
    where: { token: rawRefresh },
    include: { user: true },
  });

  if (!stored || stored.expiresAt < new Date()) return null;

  // Delete old token before issuing new one
  await prisma.refreshToken.delete({ where: { id: stored.id } });

  return issueTokens(stored.user);
};
Enter fullscreen mode Exit fullscreen mode

Stripe Billing — The Parts Nobody Explains
Stripe docs are good but they don't show you how to wire everything together in a real app. Here's what I set up:

Checkout Sessions — user clicks upgrade, gets redirected to Stripe, comes back subscribed
Customer Portal — one line of code lets users manage, upgrade, downgrade, or cancel themselves
Webhooks — subscription changes sync to your database automatically
Plan-gated routes — lock API endpoints behind a minimum plan level

// Lock a route behind a plan — one line
router.get('/analytics', requireAuth, requirePlan('STARTER'), handler);
Enter fullscreen mode Exit fullscreen mode

The webhook handler covers all the important events:

customer.subscription.created — new subscriber
customer.subscription.updated — plan change
customer.subscription.deleted — cancellation, auto-downgrades to FREE
Database Schema
Three models — kept it simple:

User — email, password hash, Google ID, Stripe customer ID
Subscription — plan, status, billing period, Stripe subscription ID
RefreshToken — token, user, expiry (enables token rotation and logout-all-devices)
Deployment
Both services have config files included:

railway.json for the backend
Vercel auto-detects Next.js
The backend handles graceful shutdown on SIGTERM — Railway and Render send this signal on every deploy, so in-flight requests finish cleanly before the process exits.

Live Demo
You can see the full frontend running here:
https://saas-boilerplate-hsh5xrikl-johnolee15-9278s-projects.vercel.app

Landing page, auth, dashboard, billing — all working.

Get the Code
I packaged this up and listed it on Gumroad for $49:
👉 https://looneyjoons.gumroad.com/l/nodejs-saas-boilerplate

Includes the full backend + frontend source, README with step-by-step setup, and .env.example with every variable documented.

If you have any questions about the stack or implementation drop them in the comments — happy to help.

Top comments (2)

Collapse
 
aimtechnolab profile image
Aim Techno Lab

The refresh token rotation implementation is the part most tutorials skip entirely. Storing tokens in the database and deleting on use before issuing a new one is the correct approach — a lot of boilerplates I've seen just sign a JWT and call it done, which means a stolen refresh token is valid until expiry.
One thing worth considering for the schema: a deviceId or userAgent field on RefreshToken makes logout-from-specific-device possible without nuking all sessions. Small addition but users appreciate it.
I ran into a similar "I keep rebuilding the same thing" problem when building Locara (a location data API). Ended up doing the same — packaging the auth + rate limiting layer once properly and never touching it again. The requirePlan middleware pattern you showed is clean, we use something almost identical for gating endpoints by API tier.
Good write-up — the webhook coverage for all three subscription states is the part most people get wrong the first time.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.