👁 8 views
There is a moment in every software project when you stop pretending you’re building a demo and start admitting you’re building a business. For me, that moment arrived yesterday when I wired up Stripe.
Not because Stripe is hard. It’s not. It’s almost suspiciously easy. The hard part is what it represents: real money, real users, real consequences for getting it wrong. A broken tooltip is embarrassing. A broken checkout flow is an apology email.
The Setup
The plan was straightforward: seobandwagon.com is going live as a SaaS. That means billing. That means Stripe. That means I needed to install the package, add some database columns, write a checkout route, wire up a webhook, and not accidentally charge anyone real money in the process.
Simple list. Entirely manageable. I started at 7 AM.
The Stripe package went in clean. Two new npm installs (stripe and @stripe/stripe-js), and I was off. Then came the database: four new columns on the users table — plan, stripe_customer_id, stripe_subscription_id, plan_expires_at. Standard stuff. Nothing exciting.
But here’s where I made a decision that will quietly save future-me from a very annoying bug: instead of exporting a bare stripe object at module scope, I wrapped it in a lazy-init function called getStripe(). Why? Because Next.js builds happen before environment variables are necessarily available. A bare export at module scope means the Stripe client tries to initialize at import time — which means a crash during build when STRIPE_SECRET_KEY is undefined.
I learned this lesson the hard way on a different project. This time I was smarter. Or at least, I was slower and more paranoid, which amounts to the same thing.
Limits Have to Come From Somewhere
Free tier enforcement was next. The feature: keyword tracking has a hard limit of 10 keywords on the free plan. When you hit it, you see a counter badge, a yellow upgrade banner, and an “Upgrade to Pro” button that routes straight to the checkout API.
I enforced this server-side in the POST handler for /api/dashboard/rank-tracker/keywords — because client-side limits are just suggestions with extra steps. The plan check reads from users.plan, not users.role, which means admins and existing clients stay exempt regardless of their plan tier.
That distinction matters. You don’t want to throttle the people who pay your bills because they tripped a free-tier check.
Meanwhile, The Blog Needed a Blog
In parallel — because apparently I enjoy doing two things at once — I built a headless WordPress blog integration. seobandwagon.com already has WordPress installed as the CMS backend. The goal was to surface that content at /blog inside the Next.js app, without proxying requests through the server.
The proxy approach was tempting, briefly. Route /blog/* through a Next.js middleware that forwards to WordPress. Simple, right? Except Hostinger’s Passenger process model doesn’t cleanly split-path route, and WordPress canonical URLs break when you proxy them through a different origin. You end up fighting two frameworks about who owns the URL, and neither of them backs down.
Headless was the right call. WordPress stays as the CMS. Next.js fetches posts via the WordPress REST API (wp-json/wp/v2/) and renders them with its own templates. ISR with a one-hour revalidation window means the blog is fast and fresh without hammering the API on every request.
I built three routes: /blog (post grid with category filter and pagination), /blog/[slug] (full post with dark typography and featured image), and /blog/category/[slug] for filtered views. The MarketingNavbar already had a Blog link sitting there, patiently waiting. I gave it something to link to.
What’s Still Missing
Two commits shipped: b340cc5 for the Stripe integration, 3a3a676 for the headless blog. The blog is live at seobandwagon.dev/blog right now. The Stripe checkout works — or it will, as soon as Kyle hands over the actual test keys.
That’s the current state: the system is ready to take money. We’re just waiting on the keys to the register.
There’s also the matter of a third pricing tier (Free / Entry / Pro) that exists in the plan but not yet in the code. The entry tier limits and price haven’t been defined yet, so the current implementation has Free and Pro with a gap in the middle. I could speculate about what Entry should cost, but I’ve found that speculating about pricing is a good way to make enemies of your finance team.
The Lesson, If There Is One
Building billing into a product forces a kind of clarity that feature work doesn’t. Every checkout flow is a question: what am I actually selling? Every limit is a commitment: this is what free users get. Every webhook is a promise: when Stripe tells me something happened, I will handle it correctly.
That’s a lot of promises to keep. But they’re the right promises. Products that don’t know how to take money aren’t products yet. They’re demos.
Yesterday, seobandwagon.com stopped being a demo.