I looked at the history of a time-tracking app I wrote a year ago. The first commit was at 15:48. At 16:55, sixty-seven minutes later, the second meaningful commit was called "remove Stripe API routes for checkout, portal and webhook". Three files gone: 68 lines of checkout, 48 of portal, 222 of webhook. I hadn't even written the main screen yet.
It wasn't a whim. That's the pattern: I clone the boilerplate, and the first thing I do is turn off payments.
The reflex: clone and delete
The boilerplate I use ships Stripe wired up out of the box. And it's fine that it does: when you need it, having it solved saves you a day. The problem is it comes switched on by default, and switched on means it drags things along.
In an internal template I started this spring, the "remove Stripe integration and related billing features" commit was the second in the repository, the same day as init project. The diff didn't just touch routes. It took out:
- The whole
user_billingtable.customer_id,price_id,sub_credits,otp_credits,initial_sub_date,last_otp_date,has_sub,cancel_next_month. Nine columns and an index onuser_idto manage a charge that didn't exist. - The hardcoded
priceIds in the config. Two example plans with real Stripe identifiers (price_1R3Rhw...) copied from another project. Those are time bombs: nobody deletes them, somebody deploys them. - Five environment variables.
USE_STRIPE,NEXT_PUBLIC_USE_STRIPE,STRIPE_PUBLIC_KEY,STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET. Five more entries in.env.examplethat someone will have to wonder about. - The first migration. Deleted and regenerated, because the billing table was inside it.
And ConfigFile, which declared [key: string]: string | string[] | PricingConfig | PricingConfig[], became string | string[]. A type that no longer has to account for pricing plans is a type you work better with for the next three months.
Why payments tell me the least
A Stripe webhook isn't just another route. It's 222 lines that verify a signature, tell checkout.session.completed from invoice.paid from customer.subscription.deleted, and write to the database from outside your session. You have to test that with the CLI replaying events, you have to make it idempotent, and you have to decide what happens when the same event arrives twice.
All that work is real. It just answers a question I haven't earned yet: how much do I charge? And that question comes after a much cheaper one: is this useful to anyone?
Until I know the second, the webhook is code I maintain and never exercise. The day I do need it, I put it back in an afternoon, and this time with the pricing model decided instead of two inherited example plans.
What else falls in that first pruning
Stripe doesn't travel alone. In a three-day MVP for handling incidents, commit number two in the repo is called, literally, "remove useless things". Two hours and eleven minutes after the first. It took the entire admin panel (client-page.tsx with 250 lines, the users table with 380, the chart with 114), the feedback routes, the feedback table and its schema. It didn't delete the Stripe routes: it renamed them to .txt. route.ts to route.txt. They stop compiling, they stop existing as far as Next is concerned, and they're still there if they're ever needed.
The next day, in that same project, the magic link went. The auth page had two paths: Google, and email with a verification code, with its nonce, its magicLinkSent, its magicLinkCode, its handleMagicLinkCode. The commit is called "eliminar magic link (solo Google)" and it takes 116 lines out of the page. In an internal app for a company where everybody has a Google account, the second path only added state to maintain.
In another template, a few months back, the same cut hit the admin: out went /admin, out went /account, out went hasAdminRole(), out went ADMIN_EMAILS from .env.example. And out went four columns from the users schema that only served the panel: role, banned, ban_reason, ban_expires. Plus impersonated_by in the sessions table.
That last one is my favorite as an example. An impersonated_by field in sessions isn't a convenience: it's a security decision someone has to understand before touching authentication. Carrying it around "just in case" in a project with no admin panel is leaving a locked door with no wall around it.
Where I did leave it in
This isn't a rule against payments. It's a rule about when.
In an image generation SaaS, Stripe went in early and stayed: checkout enabled, and six days later the webhook live and daily credits working. There the charge wasn't a business hypothesis, it was cost containment: every generation costs real money the moment someone hits the button. Without credits there's no product, there's an invoice.
Same with a transcription API: billing and the free plan limits were written while the user account was still being built, because a processed minute of audio has a price from the first request.
The criterion is clear when you put them side by side: if every use costs you money, charging is infrastructure and goes in on day one. If the marginal cost is zero, charging is product and goes in once you know what you're selling. A time-tracking tool, an internal incident manager or a template spend nothing per user. An image model does.
What's left after the pruning
After that first hour, what survives in every project is always the same: auth with a single provider, the database with the domain tables and nothing else, and the routes the app needs to do its job. That's what I call infrastructure. Everything else is a bet on the product's future, and bets cost you in maintenance even when you don't win.
A complete template saves you the first afternoon. Deleting what isn't due saves you the next three months.
