Case study · An events and entertainment brand

Moving an events brand off WordPress, with one gated path to production

We moved an events and entertainment brand off WordPress onto one application whose only road to production is a test-and-audit gate, and resolved every legacy URL that search engines reported as missing.

4 min read

At a glance

Sector
An events and entertainment brand
Duration
since August 2025
Our role
design, build, deployment and operation of the brand's web platform; product, legal and accounting decisions stay with the client
Team
TQM Invest, two engineers over the engagement
  • Next.js
  • React
  • TypeScript
  • Prisma
  • Postgres (Neon)
  • Vercel
  • GitHub Actions
  • Vitest
  • Cloudflare

Headline results

  • One gated path to production since July 2026. Every production deploy passes a dependency audit and the unit suite; lint joined the gate on September 6, 2026.
  • 21 high and 3 critical dependency advisories brought to zero so the audit gate could be switched on.
  • All 28 legacy URLs that search reported as not found were resolved: 24 permanent redirects and 4 deliberate 404s, held in place by tests.

The situation

When we started, the business lived on two unrelated websites. The brand ran on WordPress with WooCommerce, and event tickets were sold as shop products under URLs that had been indexed for years. A second line of business ran on a separate JavaScript app that we had built in 2025, with its own database host and its own deploy.

Neither site had versioned releases or a way to roll back, and two stacks meant two dependency trees to patch. We built one Next.js application to replace both. It went live in July 2026, the day WordPress was switched off, with the second app moved in as a section of the main site.

Two things had to hold through the move. Old ticket and event pages were still in search results, so their URLs could not simply break. And a broken release costs a ticket sale on the night, so production needed a gate that a red test run could not get past.

What we did

Made one workflow the only road to production

The hosting platform deploys the main branch on every push, in parallel with CI. That lets a failing run ship. We wanted to keep the platform's pull-request previews and take away only its production auto-deploy.

Production Git deploys are off in the platform config. One GitHub Actions workflow runs the checks and, only on main or a manual dispatch, deploys with the platform's CLI. Superseded test runs are cancelled; production deploys queue and are never cancelled half way. The pipeline went in one day before go-live. An illustration of its shape:

on:
  push:
    branches: [main, development]
    paths: ['src/**', 'prisma/**', 'public/**', 'package*.json',
            'next.config.ts', 'eslint.config.mjs', 'vercel.json',
            '.github/workflows/ci.yml']
jobs:
  checks:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm audit --omit=dev --audit-level=high
      - run: npx prisma generate
      - run: npm run lint
      - run: npm test
  deploy:
    needs: checks
    if: github.ref == 'refs/heads/main'
    concurrency: { group: deploy-production, cancel-in-progress: false }
    runs-on: self-hosted
    steps:
      - run: npx vercel deploy --prod --yes --token="$VERCEL_TOKEN"

The audit step could not be switched on until the dependency tree was clean. It started at 21 high and 3 critical advisories in production dependencies. We brought that to zero with scoped overrides pinned by caret range, without npm audit fix --force, which at one point proposed downgrading the framework by seven major versions. Since then every production deploy has passed the audit and the unit suite, including an urgent framework security patch in September 2026.

Found the ways the gate stopped seeing changes

A gate can fail by letting something bad through, or by no longer running at all. The second kind raises no error, and we met it three times.

The paths allowlist. A large documentation folder lives in the repository, and docs-only commits should not cost a production deploy, so the workflow runs only for listed paths. A path missing from that list means a change runs no CI and never deploys. It happened twice. public/** was missing, so a search-engine verification file was merged and then returned 404 in production; we added it on August 14, 2026. Later eslint.config.mjs was missing, so changes to lint rules ran nothing; we added it on September 6, 2026. The project notes now say that the CI allowlist and the preview-skip list are both kept by hand, so any file that changes how the app is built or gated goes into both in one commit.

Lint outside the gate. Lint was not part of CI until September 6, 2026. By then a house style rule in the customer copy had drifted to 31 violations, and npm run lint had been failing on a vendored editor plugin for some time. We fixed both and made lint a gate step that day.

Runners that never started. A release pull request showed the test job failing in two seconds with an empty log. Only the check-run annotations, fetched through the API, said the job had never started, for a reason outside the repository. With production auto-deploy turned off, CI, deploys and merged-branch clean-up had all stopped. On September 26, 2026 we registered a repository-scoped self-hosted runner as a user service on a dedicated server, with the registration token piped over SSH and never printed, and the pipeline was moving again the same day.

Three weeks after the site was verified in the search console, it reported 28 not-found pages. Every one was a legacy WordPress URL, mostly old ticket products.

A catch-all redirect for the old shop paths would have been one line. But some old slugs have no live equivalent, and redirecting them to a generic page turns an honest 404 into a soft 404, which search engines report as an error. A catch-all would also hide the next broken link.

We wrote an explicit map of 24 permanent redirects (event posts, ticket products, static and legal pages) and a list of 4 paths that are meant to 404, kept in code so they are not redirected later by accident. Five /wp-* paths answering 403 were left alone: that is the edge firewall blocking WordPress probes. The redirects emit an explicit 301, because the framework's permanent: true emits 308. Then we tested the map as data. An illustration of the checks:

describe("legacy redirects", () => {
  const sources = new Set(REDIRECTS.map((r) => r.from));

  it("never chains", () => {
    for (const { from, to } of REDIRECTS) expect(sources.has(to), `${from} -> ${to}`).toBe(false);
  });

  it("leaves intentional 404s alone", () => {
    for (const path of INTENTIONAL_404S) expect(sources.has(path), path).toBe(false);
  });

  it("only points at pages the sitemap lists", async () => {
    const listed = (await sitemap()).map((e) => new URL(e.url).pathname);
    for (const { to } of REDIRECTS) expect(listed, to).toContain(to);
  });
});

The next edit that creates a chain, redirects a page we chose to retire, or points at a page the sitemap does not list now fails a test before it can deploy.

Results

MeasureBeforeAfter
Live sites and deploys2 (WordPress shop, second app)1
Paths to productionPlatform auto-deploy, ungated1: CI with audit, lint (since September 2026) and tests
Production dependency advisories, high / critical21 / 30 / 0 when the gate went on
Legacy URLs reported as not found280 unresolved: 24 redirects, 4 deliberate 404s
Ways the gate stopped running, found and fixedUnknown3: allowlist gaps, lint missing from CI, runners that never started

Related case studies

Running into something like this?

Thirty minutes, no commitment. Bring the problem; we will tell you what we would check first and whether we are the right people for it.

All case studies