When I started on the consumer platform I work on, the website was a Next.js 10 app written in plain JavaScript. The API, the mobile app, and the rest of the platform each lived in their own codebase. They talked to each other, but nothing checked that they agreed.
Today the website, API, admin panel, scheduled jobs, CMS, and image resizer all live in one TypeScript monorepo. They share their types, database layer, and UI components, and one CI/CD pipeline builds, deploys, and rolls back all of them.
We did it while the site stayed live, with millions of visits a month, and the rule for the whole project was simple: users shouldn't notice.
What it looks like now
| App | What it does | Built with |
|---|---|---|
| web | The website: browsing, account, payments | Next.js 16, React 19, TanStack Query, Tailwind CSS v4 |
| api | Auth, content, accounts, payments | Express 5, Drizzle ORM, MySQL, Redis |
| admin | Content, users, promotions, SEO | Next.js 16, NextAuth, server actions |
| cron | Data feeds, sitemaps, exports, cache clean-up | Node.js, node-cron |
| cms | Banners, pages, FAQs, and other content | A headless CMS |
| image-resize | Resizes images on demand | AWS Lambda, Sharp |
And the shared packages they all use:
- db: the database schema and queries, written once
- types: the API's request and response shapes, so the website and the API can't drift apart
- ui: the design system shared by the website and the admin
- storage: S3 uploads and image URL helpers
- eslint-config and typescript-config: one set of rules for every app
1. One repo, one set of tools
The first step was putting everything in one place. We used pnpm workspaces to link the apps and packages, and Turborepo to run tasks across them.
Turborepo matters more than it sounds. It knows that the website depends on the shared types and ui packages, so it builds those first. It also caches results, so when nothing in an app has changed, its build is skipped.
pnpm dev --filter=web # just the website turbo run build # everything, in the right order turbo run check-types # type check every app and package
Formatting, linting, and TypeScript settings now come from shared packages. Before, each codebase had its own rules, and a function written in one style in the API looked different in the website. Now they all follow one standard, and a pre-commit hook checks it.
2. TypeScript everywhere, with no way out
Moving from JavaScript to TypeScript only helps if the types are real. So we set a few strict rules:
- Strict mode in every app.
- No
anyin backend code. Every request, query result, and response has a real type. When we truly don't know a shape, we useunknownand check it. - API shapes live in the shared
typespackage. The website and the API import the same definitions. If someone changes a response, the website stops compiling, so the bug shows up in their editor and never reaches a user.
That last one is the biggest win of the whole project. With separate codebases, the API and the website could only agree by luck. Now TypeScript checks it on every build.
3. The website: Next.js 10 to 16
Six major versions is a lot of change. Routing, data fetching, caching, images, and fonts all work differently in Next.js 16. The website now runs fully on the App Router, with React 19, the React Compiler, and Tailwind CSS v4.
Moving to the App Router also changed how we think about caching. Pages that are the same for every guest can be cached at the CDN, which is what made the AWS savings possible. And once the website was in TypeScript, finding the heavy imports in our bundle analysis got much easier, because the tooling could follow every import.
4. The API: new code, same database
The API moved to Express 5 and TypeScript, with Drizzle ORM for queries. But we did not move the data. The new API reads and writes the same MySQL database the old one did.
That decision kept the project possible. Moving the code and the data at the same time would have meant one big, risky switch-over. Keeping the database meant we could move one endpoint at a time and compare the new one with the old one.
The hard part was everything that already depended on the old behaviour:
- The mobile app. You can't force everyone to update an app. Old app versions keep calling the old endpoints for months. So the existing routes stayed frozen and working, and all new work goes into a separate, versioned set of routes.
- Logged-in users. Moving to a new auth setup must not log everyone out. Existing sessions kept working, and people moved to the new setup the next time they signed in.
- Saved data. Someone with three items saved before a deploy should see the same three items after it. We wrote a small migration step that carries old saved data over the first time it's loaded.
None of this is exciting work, but it's what makes a migration invisible. If even one of these had gone wrong, users would have noticed straight away.
5. A new admin panel
The admin panel was rebuilt as its own Next.js 16 app. It handles content, categories, users, payments, promotions, reviews, and SEO URLs.
A few choices made it much easier to build than a typical admin:
- It uses the shared
dbpackage, so the admin and the API read the same tables through the same, typed code. - It uses the shared
uipackage. We built one data table component and used it for every list page, from users to testimonials. - Every form goes through a validated server action, so bad input is rejected on the server, not just in the browser.
- There's a change log. Every edit records who changed what, with a before-and-after diff. When something looks wrong, we can see exactly when and why it changed.
6. Scheduled jobs get their own app
Data feeds for search and ad platforms, the sitemaps, CSV exports, image processing, and cache clean-up all run on a schedule. They now live in their own cron app.
Keeping them separate from the API means a heavy job, like building a feed that covers the whole site, can't slow down the API. They can also be deployed and restarted on their own. And because the cron app uses the same db and storage packages as everything else, a feed job reads data exactly the way the website does.
7. The CMS joins the repo
Content like banners, landing pages, FAQs, and policy pages is managed in a headless CMS, which now lives in the same repo and goes through the same pipeline as everything else. Images uploaded in the CMS go to S3 and are served through the same image resizer as every other image.
When someone publishes, only the affected pages are cleared from the CDN cache, so changes go live quickly without throwing away the rest of the cache. I wrote about that in the AWS post.
8. CI/CD: from "who deploys this?" to one button
We moved from Bitbucket Pipelines to GitHub Actions and set up the pipeline the way we wanted it:
On every pull request, CI runs linting and type checks across the whole monorepo. Turborepo's cache means it only re-checks what changed, so a small fix doesn't wait for every app.
Deploys are build once, deploy many. The publish step builds an app one time and stores that exact build as a release. Deploying means copying that release to the servers and reloading the app without downtime. Staging and production run the same build, so what we tested is what goes live.
Rollback is one click. Older releases are kept, so going back to the last good version means pointing the servers at a build that already exists. No rebuilding under pressure.
Each app deploys on its own. A website fix doesn't redeploy the API, and a CMS change doesn't touch payments.
We also tightened a few things that are easy to put off:
- No long-lived AWS keys in CI. GitHub Actions signs in to AWS with short-lived credentials for each run.
- Secrets stay out of the repo and out of GitHub. Apps read them from AWS at deploy time.
- Production is locked down. Production deploys only run from the main branch, and only approved people can start them.
- Updates don't pile up. Dependabot opens PRs for package, base image, and GitHub Actions updates.
9. Seeing what's happening in production
A migration this big is only safe if you can see problems quickly. Sentry catches errors in the website and the API, and that's what brought our production errors close to zero. Server metrics and logs go to Grafana, so when something is slow we can tell whether it's the app, the database, or Redis.
What I'd tell someone starting this
- Make "users shouldn't notice" the goal. Sessions, saved data, old app versions, and URLs are where migrations go wrong. Plan for them first.
- Don't move the code and the data at the same time. We kept the database and changed everything around it. That made every step small and reversible.
- Share types between the frontend and the backend. It's the single biggest benefit of a monorepo. Bugs that used to reach users now stop at compile time.
- Leave the old API alone for the clients you can't update. Freeze it, keep it working, and build new things next to it.
- Build once, deploy that build everywhere, and keep old builds. It makes deploys predictable and rollbacks boring.
- Set up monitoring before the big switch, not after. You want to know about a problem before your users tell you.