Project·

Next.js SaaS Starter: Auth, Stripe, and Docs from Day One

Open-source Next.js 14 starter with Supabase auth, Stripe subscriptions (webhook-synced), PostgreSQL RLS, and a Fumadocs documentation site. Built after repeatedly scaffolding the same stack from scratch.

Client: Open SourceService: Open Source

Problem

Every SaaS I built started the same way: wire up Supabase auth, connect Stripe subscriptions, scaffold a dashboard, set up a docs site. The existing starters each solved part of this. Taxonomy gave a clean shadcn/ui design system but had no billing. Vercel's subscription payments example had Stripe+Supabase billing but no documentation site, no blog, and used the old Pages Router.

Neither was something I could fork and ship. So I built Hikari: a single starting point that combines them.

View on GitHub →

Why It's Hard

Stripe and Supabase need to stay in sync. Stripe is the source of truth for products, prices, and subscriptions. Supabase needs a local copy for fast queries. That sync happens via webhooks — which means handling events out of order, retrying failed upserts, and dealing with foreign key constraint failures when a price webhook arrives before the product webhook. None of this is documented clearly in either platform's docs.

Row-level security as the permission layer. Instead of checking permissions in application code, Hikari uses PostgreSQL RLS policies so SELECT/UPDATE on the users and subscriptions tables is scoped to auth.uid() = id at the database level. This is the right approach for a Supabase project, but it means every query fails silently if the RLS policy is wrong — which is a painful debugging experience.

Library churn. Next.js 14 deprecated Contentlayer. Clerk and Planetscale were early experiments that got replaced. Each swap meant understanding the replacement deeply enough to wire it correctly — not just swap an import. This is the hidden cost of building on a stack that's still moving fast.

Keeping the template extensible without over-abstracting. The git log has a commit titled "Trimming edges. Less abstraction" where I removed wrapper functions that were adding indirection without value. The tension between "clean architecture" and "just make it work" is real when you're the only user.

Architecture

Loading diagram...

The interesting part is the Stripe webhook handler. When Stripe fires price.created, it upserts the price into Supabase — but the price references a product via foreign key. If the product.created webhook hasn't processed yet, the upsert fails with a FK constraint violation. The handler retries up to 3 times with a 2-second delay. It's a simple fix but easy to miss, and the symptom (prices not syncing) is confusing without knowing to look at webhook ordering.

User provisioning is handled by a database trigger. When Supabase Auth creates a user, a trigger fires handle_new_user() which inserts a row in the public users table with full_name and avatar_url extracted from the JWT metadata. This means user profile data is always consistent without any application-layer provisioning logic.

Key Engineering Decisions

Single vendor over best-of-breed. The original plan used Clerk (auth) + Planetscale (database). Two vendors, two SDKs, two billing relationships, two sets of migration paths. Switching everything to Supabase meant a single mental model: the database IS the auth store, the file storage, and the realtime layer. The customers table that maps Supabase user IDs to Stripe customer IDs is the only cross-vendor join, and it's simple.

PostgreSQL RLS over application permissions. Multi-tenancy via WHERE user_id = current_user_id() in application code is correct but fragile — one missed WHERE clause leaks data. With RLS enabled, the database refuses the query regardless of what the application sends. The downside: debugging a missing row requires ruling out RLS as the cause first, which is non-obvious if you're not used to it.

tRPC with artificial dev latency. The tRPC setup adds 100–500ms of random latency in development. This is deliberately annoying: it surfaces waterfall requests (where one tRPC call depends on another finishing) before they become production latency problems. I caught two useQuery chains that needed Promise.all before they shipped.

Fumadocs over Contentlayer. Contentlayer was the obvious choice until Next.js 14 broke it. Fumadocs is less opinionated but actively maintained and has native MDX support without a separate compilation step. The migration was a week of work. The lesson: check the maintenance signals (last commit, open issues, Next.js compatibility) before adopting a content layer.

What Failed

Contentlayer. The original docs were built on Contentlayer. Next.js 14 deprecated the experimental appDir APIs that Contentlayer relied on. It stopped working, the repo went quiet, and I had to migrate to Fumadocs mid-build. The docs site was already 40% written and had to be restructured for Fumadocs' routing conventions. A week lost.

Clerk + Planetscale. The first architecture used Clerk for auth and Planetscale for the database. Clerk worked, but it meant a separate auth domain, separate user management UI, and a JWT-to-database-ID mapping problem. Planetscale's branch-based workflow was clever but overkill for a solo project. Switching to Supabase was a two-week reset — re-implementing auth from scratch and re-writing all database queries without Prisma.

Over-abstraction. Early versions had wrapper functions around tRPC procedures and helper abstractions over Supabase queries that weren't pulling their weight. The "Trimming edges. Less abstraction" commit removed ~300 lines that were indirection without benefit. The principle: don't abstract until the repetition is obvious and the interface is stable.

Metered billing is a placeholder. The invoice.payment_succeeded webhook handler has a comment: "Successfully reset questions_counter for user." The infrastructure for per-cycle usage resets exists but the business logic is commented out. I planned to add metered billing (usage per billing period) but shipped without it. The placeholder has been there since launch.

Stripe TypeScript types. There are three @ts-ignore comments in the Stripe webhook handler where the payment method types don't match what Stripe actually sends at runtime. The correct fix is proper type narrowing; the pragmatic fix was ignoring the error. It works but it's technical debt.

What I'd Change

Validate the library before building on it. The Contentlayer incident was predictable. The repo had stale open issues about Next.js 13 compatibility and the last commit was months old. I checked whether it worked, not whether it was maintained. Those are different questions.

Decide on metered billing upfront. The usage-reset placeholder suggests I knew I wanted metered billing but deferred the decision. That left an incomplete feature that I haven't finished in 16 months. Either ship it or remove the infrastructure — half-built features rot.

Fix the Stripe types properly. The @ts-ignore pragmatism was right to ship, but wrong to leave. The TypeScript errors point at a real gap: the Stripe SDK types don't accurately reflect what the webhooks send, and the workaround should be a documented type guard, not a suppressed error.

Key Lessons

One vendor beats two when the tradeoffs are comparable. Supabase handles auth, database, storage, and realtime. The single-vendor model means one SDK to learn, one dashboard to check, one billing relationship, and a native join between auth and data. If the functionality is comparable, the operational simplicity usually wins.

Webhook ordering is a real correctness problem. The FK retry logic isn't a nice-to-have — without it, Stripe products and prices fall out of sync silently. Any webhook-driven sync needs explicit ordering guarantees or retry logic. This is the non-obvious part of Stripe integrations that most tutorials skip.

Lines of TypeScript
9.5K
Commits
44
Forced library pivots
3

More projects

Autonomous Security Agent Built on ADK-Go and Gemini

G.O.L.E.M. — an autonomous security agent that finds business-logic vulnerabilities using typed Go tool schemas, dual-browser perception, and model rotation. Built in one day for Google's Gemini challenge.

Read more

Go Web Scraper with chromedp, LightPanda, and Asynq

Go scraper that converts JS-heavy pages to LLM-ready Markdown. Migrated from Playwright to chromedp + LightPanda, cutting the Docker image from 1.51 GB to 24.6 MB.

Read more