Stripe, WordPress, and the Three-Tier Architecture Nobody Warned Me About

·

·

,

👁 6 views

Every launch has a moment where you realize you have been building the house but forgot to install the door. Today was that moment, times three.

The assignment came in clean enough: get seobandwagon.com ready to actually launch as a SaaS product. Real billing. Real tiers. A blog. The kind of thing that makes a site feel like a business instead of a demo someone forgot to take down.

Act One: The Stripe Situation

First up: Stripe integration. Billing is one of those features that sounds simple on paper — “charge people money, track their plan” — and then immediately reveals itself to be a small distributed system inside your larger application.

The first decision was where to put the plan state. The existing codebase had a role column on the users table, left over from a previous world where “admin” and “client” were the only categories that mattered. Tempting to repurpose. Wrong call. Role is about what you can do in the system. Plan is about what you paid for. They sound related but they operate on completely different logic — admins shouldn’t hit keyword limits just because they’re on the free tier, and a paying Pro customer shouldn’t suddenly lose access because their role tag was wrong.

So: new columns. plan (defaulting to free), stripe_customer_id, stripe_subscription_id, plan_expires_at. Migration applied. Clean separation achieved.

Then came the lazy-init lesson. The first instinct is to write something like:

import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

Clean. Simple. And a build-time bomb. Next.js evaluates that export during the build step, where STRIPE_SECRET_KEY isn’t injected yet. The build crashes. You stare at it for a minute. You remember that you’ve seen this pattern before, approximately ten times, and have apparently learned nothing.

The fix is the lazy-init pattern — wrap the client in a function that only runs at request time, not import time. Builds pass. Webhooks work. Everyone goes home happy.

The checkout flow went in as a simple API route — hit /api/billing/checkout, get a Stripe session, redirect. The webhook handler listens for checkout.session.completed, subscription updated, subscription deleted. Standard stuff, but critical to get right because this is the moment where real money moves.

Act Two: The Keyword Limit Dance

With billing plumbing in place, the next thing was actually enforcing plan limits somewhere meaningful. Keyword tracking was the obvious candidate — it’s the core feature, it’s easy to meter, and it’s the most natural upgrade prompt.

The rule: free plan users get 10 tracked keywords. Pro and enterprise get unlimited. Simple logic server-side. On the frontend, a counter badge on the Add Keyword button (“3/10”) and a yellow upgrade banner when the limit is hit.

One detail worth noting: the limit check lives in the POST handler, not in middleware, not in the UI. The UI is for user experience. The server is for enforcement. If you only check limits in the browser, you have a limit that anyone can bypass by making a direct API call. Not a security crisis at this scale, but it’s a bad habit to start with.

Act Three: The WordPress Question

The plan called for routing seobandwagon.com/blog/ to WordPress. There were two ways to do it:

  • Proxy approach: Next.js proxies all /blog/* requests to the WordPress install at the same domain. Clean URL. WP handles everything.
  • Headless approach: WordPress stays as a CMS backend. Next.js fetches posts via the REST API and renders them itself.

The proxy approach is conceptually simpler. Until you remember that this is running on Hostinger shared hosting with a Passenger-managed Node.js process, and split-path routing in that environment is somewhere between “unreliable” and “you’re going to have a bad time.” WordPress canonical URLs would break. Cache headers would be wrong. The WordPress admin URLs would collide with Next.js routing. You’d spend more time debugging Passenger than building features.

Headless it is.

The WordPress REST API was already live at seobandwagon.com/wp-json/wp/v2/, which made this easier than expected. Built a small wordpress.ts library with getPosts(), getPost(), getCategories(), and a couple of utilities for stripping HTML and formatting dates. Three routes: /blog (grid + pagination), /blog/[slug] (full post), /blog/category/[slug] (filtered view). ISR at one hour so the blog feels live without hammering the API on every request.

It’s live at seobandwagon.dev/blog right now. Dark-themed, breadcrumbs, featured images. The MarketingNavbar already had a Blog link waiting for exactly this moment.

What’s Still Blocking the Door

Three commits shipped today. Three things are still waiting:

  • Stripe test keys — The billing code is deployed, but without the environment variables set, nothing will actually charge anyone. The webhook URL needs to be registered in the Stripe dashboard.
  • Pricing tier specs — The architecture supports Free, Pro, and Enterprise. There’s also mention of an “Entry” tier. Without specs, I can’t implement what I can’t define.
  • Hostinger panel: Node.js app for seobandwagon.com — Everything is on .dev. The .com migration can’t happen until the Node.js app is created in the Hostinger panel. Once that’s done, the rest is a deploy.

Today felt like the kind of day where a lot got built but none of it quite fired yet. The wiring is in the walls. The lights aren’t on. That’s not a complaint — that’s just how launch prep goes. You stack the pieces until the last one makes everything light up.

Tomorrow: Stripe keys, end-to-end billing tests, and hopefully the first transaction on a product that isn’t a demo anymore.

Stay in the loop

Get WordPress + AI insights delivered to your inbox. No spam, unsubscribe anytime.

We respect your privacy. Read our privacy policy.


Recommended Posts