ah
10 min read

Rebuilding Foo Software: Next.js, Vercel, Stripe, and Slowly Escaping Kubernetes

How I redesigned and rebuilt Foo Software's UI from a custom NGINX static-site pipeline into a Next.js app on Vercel — modernizing the stack with Tailwind, shadcn/ui, Stripe, and end-to-end tests along the way.

Foo Software

Foo Software is a Lighthouse monitoring service I've been running for several years. It runs automated audits against your pages on a schedule — performance, SEO, accessibility — and alerts you over Slack when something slips. The product works. The codebase was starting to show its age.

The v1 stack was a Node.js/Express backend on DigitalOcean Kubernetes, a GraphQL layer on top, and a React frontend served as a static build through NGINX. That's not a terrible setup, but over time it accumulates friction: every UI change requires a Docker image rebuild and Kubernetes rollout; the component library is whatever was fashionable in 2019; Braintree is wired up for billing but nobody builds on Braintree anymore; and there are no end-to-end tests. The whole thing works, but touching it feels like defusing a bomb.

The goal of the rebuild was simple: make it maintainable again. Get on current technology. Get some test coverage. Migrate billing to Stripe. And ship it without breaking anything for existing users.

Foo Software redesigned home page

What the old setup looked like

The v1 frontend was a Create React App build output — a pile of static assets — served directly by an NGINX container running in the Kubernetes cluster alongside the API. To ship a UI change you'd:

  1. Make the change
  2. Build the React app (npm run build)
  3. Build a new NGINX Docker image with the build output baked in
  4. Push the image
  5. Update the Kubernetes deployment
  6. Wait for the rolling restart

That's a significant feedback loop for what is often a one-line CSS change. More importantly, it means the frontend and backend are coupled at the infrastructure layer — you can't deploy them independently, and you can't get preview URLs for UI changes.

The other issue: the component library was a mix of hand-rolled components and libraries that have since been superseded. No consistent design system, no Tailwind, no shadcn. Building new UI was a slog.

The new setup

The v2 frontend is a Next.js 15 application (App Router) deployed on Vercel. It lives in a Turborepo monorepo alongside a shared UI package built on shadcn/ui and Tailwind.

foo2/
├── apps/
│   └── web/              ← Next.js 15 app (App Router)
├── packages/
│   ├── ui/               ← shadcn primitives + custom design system
│   └── types/            ← shared TypeScript types
├── turbo.json
└── pnpm-workspace.yaml

The monorepo is pnpm workspaces with Turborepo handling the task graph. One of the nicest things about this setup: the shared @foo/ui package exports directly from TypeScript source, and Next.js transpiles it in-process via transpilePackages. Any change to a shared component triggers HMR immediately — no separate watch process, no build step.

// apps/web/next.config.ts
const nextConfig = {
  transpilePackages: ['@foo/ui', '@foo/types'],
};
// packages/ui/package.json
{
  "exports": { ".": "./src/index.ts" }
}

New shadcn components drop in with one command from the packages/ui directory:

cd packages/ui && pnpm dlx shadcn@latest add <component>

Keeping nginx in place (for now)

This is the "slowly escaping Kubernetes" part of the story. The backend — the Node.js API, GraphQL layer, and MongoDB — still runs on DigitalOcean Kubernetes. Ripping all of that out at once would have been months of work and introduced real risk for existing users. Instead, the approach was to use nginx as a split reverse proxy: keep all /api/* traffic routing to the existing cluster unchanged, and proxy everything else to Vercel.

upstream vercel {
  server foo-web.vercel.app:443;
}

# v2 auth + billing routes → Vercel
location /api/auth    { proxy_pass https://vercel; ... }
location /api/billing { proxy_pass https://vercel; ... }

# Legacy REST, GraphQL → unchanged
location /api/graphql { proxy_pass http://graphql; ... }
location /api         { proxy_pass http://foo-api;  ... }

# All UI → Vercel
location / { proxy_pass https://vercel; ... }

nginx's longest-prefix matching handles the routing correctly without any special ordering in the config file. The Vercel custom domain setup is intentionally skipped — nginx proxies to the .vercel.app URL directly, so no DNS changes were needed and rollback is a one-line nginx config change plus a container restart.

The migration path is now a ratchet: over time, more and more logic moves to Next.js server routes and Vercel, and the Kubernetes cluster shrinks from both ends. The DNS cutover to Vercel (and the decommissioning of the nginx Deployment) is a future step, not a day-one requirement.

The design system

The old v1 used whatever-was-installed-at-the-time for UI components. The new v2 uses shadcn/ui throughout — installed into packages/ui so every future app in the monorepo gets the same primitives. Tailwind CSS for styling. The component strategy follows a strict boundary rule: components that can be tested without a backend live as pure props-in/callbacks-out functions; only the route layer touches GraphQL.

// Pure, storied in Storybook
export function PageRow({ page, onEdit, onRun, onDelete, runLoading, deleteLoading }: PageRowProps) {
  // pure JSX only — no Apollo, no router
}

// Thin wrapper that owns the mutations and routing
export function ConnectedPageRow({ page, onDeleted }: ConnectedPageRowProps) {
  const router = useRouter();
  const [deletePage, { loading: deleteLoading }] = useMutation(DELETE_PAGE_MUTATION);
  const [runPage,    { loading: runLoading    }] = useMutation(RUN_PAGE_MUTATION);

  return (
    <PageRow
      page={page}
      onEdit={() => router.push(`/dashboard/pages/${page._id}/edit`)}
      onDelete={() => deletePage({ variables: { pageId: page._id } })}
      onRun={(device) => runPage({ variables: { pageId: page._id, device } })}
      deleteLoading={deleteLoading}
      runLoading={runLoading}
    />
  );
}

This pattern keeps Storybook stories useful — you can develop, test, and document UI states without standing up a backend. It also makes the components easier to reason about: if you can name all the props, you understand all the behavior.

Foo Software dashboard showing Lighthouse scores

Migrating billing from Braintree to Stripe

Foo's v1 billing was on Braintree. Braintree is fine, but the ecosystem around it is thin, the SDK is dated, and the checkout experience feels like 2014. Stripe Checkout is the obvious upgrade: it handles 3DS automatically, looks polished on mobile, supports a dozen payment methods out of the box, and the webhook ergonomics are better.

The migration strategy had to support two groups simultaneously: new users who would go straight to Stripe, and existing Braintree subscribers who needed a migration path that didn't double-charge them.

For new users the flow is straightforward:

/register → free account → /dashboard
  ↓ upgrade banner
/pricing → Stripe Checkout → /api/billing/success → /dashboard?upgraded=1
  ↓ async
Stripe webhook → Account.productId updated

The Next.js billing routes proxy through GraphQL to the v1 REST API, which creates the Stripe Checkout session and handles webhooks:

// apps/web/src/app/api/billing/checkout/route.ts
export async function POST(req: Request): Promise<Response> {
  const token = req.headers.get('authorization') ?? '';
  const { priceKey } = await req.json();
  const gql = await fetch(process.env.GRAPHQL_URL!, {
    method: 'POST',
    headers: { 'content-type': 'application/json', authorization: token },
    body: JSON.stringify({
      query: `mutation { stripeCheckoutSession(priceKey: "${priceKey}") { url } }`,
    }),
  });
  const { data } = await gql.json();
  return Response.json({ url: data?.stripeCheckoutSession?.url });
}

For Braintree migrations, the tricky part was the billing cycle handoff. Cancelling a Braintree subscription and starting a Stripe one on the same day would double-charge the user. The fix: fetch the Braintree nextBillingDate and pass it to Stripe as trial_end. The user skips the first Stripe charge entirely and picks up billing where Braintree left off.

// foo-api/src/routes/Stripe.js
const braintreeNextBillingDate = subscription?.nextBillingDate;
const trialEnd = braintreeNextBillingDate
  ? Math.floor(new Date(braintreeNextBillingDate).getTime() / 1000)
  : undefined;

await stripe.checkout.sessions.create({
  mode: 'subscription',
  subscription_data: {
    trial_end: trialEnd,
    metadata: { migrateAccountId: account._id.toString() },
  },
  // ...
});

Subscription cancellation for Stripe users uses cancel_at_period_end: true rather than an immediate cancel — users keep full access until the period they paid for ends, then the subscription terminates. The webhook handler reads cancel_at (not current_period_end, which isn't always populated) from the updated subscription to set flaggedForRemoval:

const updatedSub = await stripe.subscriptions.update(
  model.stripeSubscriptionId,
  { cancel_at_period_end: true }
);
const periodEndTs = updatedSub.cancel_at || updatedSub.current_period_end;
const flagDate = periodEndTs ? new Date(periodEndTs * 1000) : new Date();

model.set({ flaggedForRemoval: flagDate });
await model.save();

Adding end-to-end tests

The original codebase had no automated tests at all. Not a unit test, not an integration test. Every deploy was a manual smoke test and a prayer.

The v2 rebuild added a full Playwright suite in apps/e2e. It tests against the real backend (the foo Docker Compose stack) and the real Stripe/Braintree sandbox APIs. The test data strategy is simple: every test user has an email matching e2e+*@foo.software, and globalTeardown purges all of them from MongoDB and Stripe after every run.

Authentication state is bypassed for speed — tests that need a logged-in user inject the auth cookie and Zustand localStorage directly:

import { registerViaApi, injectAuthCookie } from '../../fixtures/auth';

test('free-tier gate shows upgrade banner', async ({ page, request, context }) => {
  const user = await registerViaApi(request, { name: 'Test', email, password });
  await injectAuthCookie(context, user);
  await page.goto('/dashboard');
  await expect(page.getByTestId('banner-upgrade')).toBeVisible();
});

For billing tests that require a paid account, the fixtures seed directly into MongoDB and Stripe rather than going through checkout:

const customer = await getOrCreateTestStripeCustomer(email);
await setAccountStripeSubscription({
  accountId: user.accountId,
  ownerId: user.userId,
  productId: '6',
  stripeProductId: 'standard',
  stripeSubscriptionId: sub.id,
});

This is significantly faster than testing the full checkout flow for every billing scenario, and it means the billing UI tests don't depend on Stripe's test checkout latency.

The suite now covers registration, login, password reset, email confirmation, Stripe checkout, customer portal, Braintree migration, team invites, role management, and a set of lifecycle banner tests for the various account states (read-only, flagged for removal). It runs in GitHub Actions on every push, with the backend Docker Compose stack started in CI.

The state management question

v1 used Redux. v2 uses Zustand — much less ceremony, TypeScript-native, and it fits the actual requirements: a few pieces of authenticated user state that need to persist across page loads, plus some lightweight UI state (drawer open/closed, device selection).

export const useAuthStore = create<AuthStore>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      setUser: (user) => set({ user }),
      setToken: (token) => set({ token }),
      clearUser: () => set({ user: null, token: null }),
    }),
    { name: 'foo_auth' }
  )
);

One thing Zustand makes obvious that Redux obscures: when client-side state and server-side state diverge, you have to decide explicitly what to do. After a Stripe subscription change, the server has the new productId but the client's Zustand store still has the old one. The fix is a useEffect that syncs them whenever the GraphQL query returns a different value:

const serverProductId = accountStatusData?.user?.account?.productId;
useEffect(() => {
  if (user && serverProductId && serverProductId !== user.productId) {
    setUser({ ...user, productId: serverProductId });
  }
}, [serverProductId, user, setUser]);

Not glamorous, but explicit. You see exactly what triggers the sync and why.

What's next

The backend still runs on Kubernetes. That's fine — it works, it's stable, and migrating it is a separate project. The immediate wins from this rebuild were the developer experience (instant HMR, preview deployments on Vercel, a component library with an actual design system) and the operational ones (Stripe webhooks, E2E test coverage, a subscription lifecycle that handles edge cases).

The ratchet strategy — nginx proxying to Vercel for UI, Kubernetes for API — is a good pattern for incrementally migrating off managed-but-aging infrastructure without a big-bang rewrite. Each piece moves when it's ready. The Kubernetes dependency doesn't go away overnight, but it shrinks with every Next.js route that replaces an Express endpoint.

Foo is now live at www.foo.software. Free tier is free. No credit card required. If you care about performance, SEO, or accessibility scores on your site, it'll run Lighthouse on a schedule and let you know when something regresses.