I Wasted 6 Weeks Building SaaS Boilerplate (Again). Here's What Finally Made Me Stop.

I Wasted 6 Weeks Building SaaS Boilerplate (Again). Here's What Finally Made Me Stop.

Source: Dev.to

The Graveyard of Almost-Shipped Ideas ## Why Existing Solutions Didn't Work For Me ## What I Actually Needed ## Authentication (The Iceberg) ## Team Management ## Billing ## The "Boring" Stuff That Takes Forever ## The Stack I Landed On ## Backend: AdonisJS 6 ## Frontend: Nuxt 4 + Vue 3 ## UI: Tailwind CSS 4 + shadcn-vue ## AI: Vercel AI SDK ## The Technical Decisions That Mattered ## 1. Background Jobs for Everything Email ## 2. Separate API Services from Controllers ## 3. Type Everything ## 4. MJML for Emails ## What I Learned Building This ## 1. "Simple" Features Aren't Simple ## 2. Testing Saves More Time Than It Costs ## 3. Design Systems > Custom Designs ## 4. Premature Abstraction Is Real ## 5. Ship Boring Tech ## The Result ## For Those Who'll Build Their Own ## What's Next? Every side project started the same way. "This time I'll finally ship fast." Then I'd spend the first month building: By week 6, I still hadn't written a single line of my actual product. Sound familiar? I've been building SaaS products for years. My GitHub is a cemetery of half-finished projects—not because the ideas were bad, but because I ran out of steam rebuilding the same infrastructure every time. The worst part? I knew it was happening. I'd tell myself "this is the last time I build auth from scratch." Then three months later, I'm debugging magic link token expiration at 2 AM. Again. I tried the alternatives: Building "just the auth part" myself — LOL. "Just auth" becomes auth + email verification + password reset + magic links + rate limiting + session management. There's no "just." After yet another abandoned project, I wrote down what I kept rebuilding: Real SaaS products have teams. That means: Stripe is powerful but complex. Every project needs: After experimenting with many combinations, here's what clicked for me: I wanted Laravel's developer experience but in TypeScript. AdonisJS delivers exactly that: Batteries included: ORM, validation, auth, mail, queues—all first-party, all TypeScript. Vue's reactivity model just makes sense to my brain. Nuxt adds: I'm not a designer. shadcn gives me beautiful, accessible components I can actually customize: 40+ components, all with dark mode, all following the same design system. Every SaaS will need AI features soon. I integrated OpenAI, Anthropic, and Google from day one: Never send emails synchronously. Ever. Your API responds instantly, emails go out reliably, and one slow SMTP server doesn't tank your request. Controllers handle HTTP. Services handle business logic. Controllers stay readable. Services become testable and reusable. Full TypeScript means: Catches bugs at compile time. Enables autocomplete everywhere. HTML emails are a nightmare. MJML makes them tolerable: Compiles to bulletproof HTML that works in Outlook 2007. (Yes, people still use that.) Magic link auth sounds easy: generate token, send email, verify token. Reality: Token expiration, rate limiting, preventing token reuse, handling expired tokens gracefully, invalidating old tokens when new ones are requested, email deliverability... I have 195+ tests. Every time I refactor something, they catch regressions I would've shipped to production. I spent years fighting CSS. Now I use shadcn components and customize the CSS variables. Consistent, accessible, fast. My first version had a "notification service" that abstracted emails, SMS, and push notifications. I only needed email. Now I build the simplest thing that works and abstract when there's a real need—not a hypothetical one. I could've used the hot new framework. Instead: PostgreSQL (40 years of reliability), Redis (battle-tested), AdonisJS (stable, well-documented). When something breaks at 3 AM, I want boring. Boring has Stack Overflow answers. I finally stopped rebuilding and started shipping. I packaged everything into Nuda Kit — the SaaS starter kit I wish existed when I started. It's not free (I spent months building this), but if you value your time, it might save you weeks: If you're going to build from scratch anyway (I respect that), here's my advice: I'm genuinely curious: What features do you keep rebuilding? For me it was auth and billing. For you it might be file uploads, real-time notifications, or analytics dashboards. Drop a comment—I want to know what slows people down. Building something? I'd love to hear about it. Find me on Twitter or check out Nuda Kit if you want to skip the boilerplate and start shipping. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: What I thought: "Add a login form" What it actually is: ├── Email/password registration ├── Email verification flow ├── Magic link authentication ├── Password reset ├── Session management ├── Rate limiting ├── Social OAuth (Google, GitHub, Facebook) └── Account deletion (GDPR compliance) Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: What I thought: "Add a login form" What it actually is: ├── Email/password registration ├── Email verification flow ├── Magic link authentication ├── Password reset ├── Session management ├── Rate limiting ├── Social OAuth (Google, GitHub, Facebook) └── Account deletion (GDPR compliance) CODE_BLOCK: What I thought: "Add a login form" What it actually is: ├── Email/password registration ├── Email verification flow ├── Magic link authentication ├── Password reset ├── Session management ├── Rate limiting ├── Social OAuth (Google, GitHub, Facebook) └── Account deletion (GDPR compliance) COMMAND_BLOCK: // Clean, expressive syntax Route.group(() => { Route.post('/teams', [CreateTeamController]) Route.get('/teams/:id/members', [GetTeamMembersController]) }).middleware('auth') Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: // Clean, expressive syntax Route.group(() => { Route.post('/teams', [CreateTeamController]) Route.get('/teams/:id/members', [GetTeamMembersController]) }).middleware('auth') COMMAND_BLOCK: // Clean, expressive syntax Route.group(() => { Route.post('/teams', [CreateTeamController]) Route.get('/teams/:id/members', [GetTeamMembersController]) }).middleware('auth') CODE_BLOCK: <Dialog> <DialogTrigger as-child> <Button>Invite Member</Button> </DialogTrigger> <DialogContent> <!-- Accessible, animated, customizable --> </DialogContent> </Dialog> Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: <Dialog> <DialogTrigger as-child> <Button>Invite Member</Button> </DialogTrigger> <DialogContent> <!-- Accessible, animated, customizable --> </DialogContent> </Dialog> CODE_BLOCK: <Dialog> <DialogTrigger as-child> <Button>Invite Member</Button> </DialogTrigger> <DialogContent> <!-- Accessible, animated, customizable --> </DialogContent> </Dialog> CODE_BLOCK: // Switch between providers seamlessly const result = await aiService.streamText({ messages, modelName: 'claude-sonnet-4-20250514' // or 'gpt-4o', 'gemini-2.5-pro' }) Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // Switch between providers seamlessly const result = await aiService.streamText({ messages, modelName: 'claude-sonnet-4-20250514' // or 'gpt-4o', 'gemini-2.5-pro' }) CODE_BLOCK: // Switch between providers seamlessly const result = await aiService.streamText({ messages, modelName: 'claude-sonnet-4-20250514' // or 'gpt-4o', 'gemini-2.5-pro' }) CODE_BLOCK: // Don't do this await mail.send(new VerifyEmailNotification(user)) // Do this await queue.dispatch(SendVerificationEmailJob, { userId: user.id }) Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // Don't do this await mail.send(new VerifyEmailNotification(user)) // Do this await queue.dispatch(SendVerificationEmailJob, { userId: user.id }) CODE_BLOCK: // Don't do this await mail.send(new VerifyEmailNotification(user)) // Do this await queue.dispatch(SendVerificationEmailJob, { userId: user.id }) CODE_BLOCK: // Controller: thin async handle({ request, response }) { const data = await request.validateUsing(createTeamValidator) const team = await teamService.create(data, auth.user) return response.created(team) } // Service: all the logic class TeamService { async create(data: CreateTeamData, owner: User) { const team = await Team.create(data) await team.related('members').create({ userId: owner.id, role: 'owner' }) return team } } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // Controller: thin async handle({ request, response }) { const data = await request.validateUsing(createTeamValidator) const team = await teamService.create(data, auth.user) return response.created(team) } // Service: all the logic class TeamService { async create(data: CreateTeamData, owner: User) { const team = await Team.create(data) await team.related('members').create({ userId: owner.id, role: 'owner' }) return team } } CODE_BLOCK: // Controller: thin async handle({ request, response }) { const data = await request.validateUsing(createTeamValidator) const team = await teamService.create(data, auth.user) return response.created(team) } // Service: all the logic class TeamService { async create(data: CreateTeamData, owner: User) { const team = await Team.create(data) await team.related('members').create({ userId: owner.id, role: 'owner' }) return team } } CODE_BLOCK: // API responses are typed interface TeamMember { id: number userId: number role: 'owner' | 'admin' | 'member' user: Pick<User, 'id' | 'firstName' | 'lastName' | 'email'> } // Frontend knows exactly what to expect const { data } = await getTeamMembers(teamId) // data.role is 'owner' | 'admin' | 'member', not string Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // API responses are typed interface TeamMember { id: number userId: number role: 'owner' | 'admin' | 'member' user: Pick<User, 'id' | 'firstName' | 'lastName' | 'email'> } // Frontend knows exactly what to expect const { data } = await getTeamMembers(teamId) // data.role is 'owner' | 'admin' | 'member', not string CODE_BLOCK: // API responses are typed interface TeamMember { id: number userId: number role: 'owner' | 'admin' | 'member' user: Pick<User, 'id' | 'firstName' | 'lastName' | 'email'> } // Frontend knows exactly what to expect const { data } = await getTeamMembers(teamId) // data.role is 'owner' | 'admin' | 'member', not string CODE_BLOCK: <mj-section> <mj-column> <mj-button href="{{ url }}"> Verify Your Email </mj-button> </mj-column> </mj-section> Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: <mj-section> <mj-column> <mj-button href="{{ url }}"> Verify Your Email </mj-button> </mj-column> </mj-section> CODE_BLOCK: <mj-section> <mj-column> <mj-button href="{{ url }}"> Verify Your Email </mj-button> </mj-column> </mj-section> COMMAND_BLOCK: test('user can accept team invitation', async ({ client }) => { const invitation = await InvitationFactory.create() const response = await client .post(`/invitations/${invitation.token}/accept`) .loginAs(invitedUser) response.assertStatus(200) await invitation.refresh() assert.equal(invitation.status, 'accepted') }) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: test('user can accept team invitation', async ({ client }) => { const invitation = await InvitationFactory.create() const response = await client .post(`/invitations/${invitation.token}/accept`) .loginAs(invitedUser) response.assertStatus(200) await invitation.refresh() assert.equal(invitation.status, 'accepted') }) COMMAND_BLOCK: test('user can accept team invitation', async ({ client }) => { const invitation = await InvitationFactory.create() const response = await client .post(`/invitations/${invitation.token}/accept`) .loginAs(invitedUser) response.assertStatus(200) await invitation.refresh() assert.equal(invitation.status, 'accepted') }) - Authentication with email verification - "Forgot password" flows - Team invitations - Stripe subscriptions - Email templates that don't look terrible - Next.js starter kits — Great ecosystem, but I wanted Vue. Personal preference, but it matters when you're spending months in a codebase. - Laravel starters — Solid, but I wanted TypeScript end-to-end. No context switching between PHP and JavaScript. - Firebase/Supabase — Fantastic for MVPs, but I always hit walls when I needed custom business logic. Vendor lock-in anxiety is real. - Creating/deleting teams - Inviting members via email - Role-based permissions (owner, admin, member) - Handling invitation acceptance/rejection - Team switching in the UI - Checkout sessions - Subscription management - Customer portal integration - Webhook handling - Invoice display - Plan upgrades/downgrades - Beautiful, responsive email templates - Dark mode that actually works - Form validation everywhere - Loading states and error handling - A landing page that doesn't look like a developer made it - File-based routing - SSR when I need it - Auto-imports that don't feel magical - ✅ Full authentication — Email/password, magic links, social OAuth, verification, password reset - ✅ Team management — Create teams, invite members, manage roles - ✅ Stripe billing — Subscriptions, checkout, portal, invoices, webhooks - ✅ AI integration — OpenAI, Anthropic, Google with streaming chat UI - ✅ Beautiful UI — 40+ shadcn components, dark mode, full landing page - ✅ Production-ready — TypeScript everywhere, 35+ tests, background jobs, Docker setup - Timebox auth. If you're still building auth after 2 weeks, step back and reassess. - Use background jobs from day one. Retrofitting async processing into a sync codebase is painful. - Pick boring, stable technologies. AdonisJS, Nuxt, Postgres. They'll still be here in 5 years. - Write tests for critical paths only. Auth, billing, team permissions. Skip tests for UI tweaks. - Ship the ugly version first. You can make it pretty after you know people want it. - Don't abstract too early. You don't need a "notification service" when you only send emails.