Scaling Next.js to 10 Million Product Listings Without Losing Your Mind
How we rearchitected IndustryBuying's storefront to handle a catalog that doubled every eight months — and the ISR, edge caching, and search lessons that made it possible
A hands-on account of consolidating Next.js, Angular, React Native, and NestJS into a single Turborepo monorepo — and how AI-assisted engineering changed the way our team ships
There’s a particular kind of engineering pain that doesn’t show up in your error logs. It lives in the 40-minute Slack thread trying to coordinate a shared component change across three repositories. It lives in the “wait, which version of that utility are you importing?” conversation. It lives in the CI pipeline that takes 22 minutes to run because nobody has been brave enough to question why.
At IndustryBuying, we hit that wall in late 2025. We were running four separate codebases — a Next.js customer-facing storefront, an Angular internal ops dashboard, a React Native mobile app, and a NestJS backend API — each in its own repository, each with its own CI/CD setup, its own package.json, and its own subtle divergence from what the other repos were doing. We had four tsconfig.json files that had all drifted from each other. We had three different versions of the same date-formatting utility copy-pasted across repos.
We knew we needed a monorepo. What we didn’t know was how profoundly AI-assisted engineering — specifically using Claude — would change not just how fast we migrated, but how fast we now ship everything after it.
This post is an honest account of what that migration looked like: the architecture decisions, the tooling choices, the Claude prompts that saved us days of work, and the concrete metrics on the other side.
Before we dive into the solution, let me paint an accurate picture of the problem. Our codebase topology looked like this:
industrybuying/
├── storefront/ # Next.js 14 — customer-facing web app
├── ops-dashboard/ # Angular 17 — internal operations tool
├── mobile/ # React Native 0.73 — iOS + Android
└── api/ # NestJS — monolithic backend API
Each repo had its own:
package.json with independently versioned dependencies.github/workflows/ directories)The shared types problem was the most painful. We had a Product type defined in the API, then a slightly different Product type in the storefront, then a React Native version that had one extra field that someone added during a hackathon and never cleaned up. When the backend team added a new field, there was a manual 3-repo PR dance every time.
Our deployment process for a full-stack feature looked like this:
api/ → review → merge → deploy → waitstorefront/ → review → merge → deploy → waitmobile/ → review → merge → submit to app store review → wait 2–3 daysAverage time from “feature complete” to “live for all users”: 11 days.
We evaluated Nx, Lerna, and Turborepo. The decision came down to three things:
1. Turborepo doesn’t require you to restructure your build system. Nx is powerful but opinionated — it wants to own your build configuration. With Turborepo, you keep your existing next build, ng build, react-native bundle, and nest build commands. It orchestrates them; it doesn’t replace them.
2. Remote caching is a first-class citizen. Turborepo’s remote cache (via Vercel or a self-hosted solution) means that if your CI runs a build that another developer already ran with the same inputs, it restores from cache. This alone cut our CI time from 22 minutes to under 6 on cached runs.
3. Task dependency graphs are simple to define. Expressing “the storefront build depends on the API types package being built first” is three lines in turbo.json. In Nx, that’s a more involved configuration.
We broke the migration into four phases:
packages/We estimated Phase 1 alone would take two weeks. Between identifying what was shared, resolving the type divergences, and writing the new packages — it was going to be a grind.
It took three days. Here’s why.
I want to be specific here, because vague claims about “AI accelerating development” are everywhere and mean nothing. Let me walk through concrete examples of what Claude did in this migration.
We had four diverged Product interfaces. I gave Claude all four versions and asked it to produce a canonical, backwards-compatible interface that all four codebases could adopt, with an explanation of every conflict it found.
The prompt was essentially:
“Here are four TypeScript interfaces for
Productfrom four different codebases in our company. Identify all conflicts and field mismatches. Produce a single canonical interface that is a superset of all four, making any new fields optional with appropriate types. For each field that differs between implementations, explain why the conflict likely exists and what the correct resolution is.”
Claude came back with a 47-field canonical interface, a table of 12 conflicts with explanations, and three fields it flagged as potentially erroneous duplicates (they were — a 2022 refactor that never got cleaned up). It also wrote the migration type guards (isLegacyProduct, isV2Product) we’d need during the transition period.
What would have taken a full-day architecture meeting took 20 minutes.
Moving four codebases into a monorepo means thousands of import statements need to change. What was:
// Before — in storefront/
import { formatCurrency } from '../../utils/currency';
import { Product } from '../../types/product';
Becomes:
// After — in apps/storefront/
import { formatCurrency } from '@ib/utils';
import { Product } from '@ib/types';
I asked Claude to write a Node.js script that would:
@ib/* package paths--write flagIt produced the script in one pass. It even added a report at the end showing which files were modified and flagging any imports it wasn’t confident about. We ran it on 180,000 lines of TypeScript across four codebases. It got 97% of them right. The remaining 3% were edge cases it had flagged for manual review.
I gave Claude our existing four GitHub Actions workflows and asked it to produce a single consolidated turbo.json and a unified ci.yml that:
lint, test, and build tasks with correct dependency orderingThe result was cleaner than what any of us would have written from scratch, because Claude had already seen every Turborepo edge case in its training data and avoided the common mistakes (like forgetting to set "cache": false on deploy tasks).
Four diverged configs needed to become one shared base with per-app overrides. Claude analyzed all four, identified the meaningful differences (versus accidental drift), and produced:
packages/
└── config/
├── eslint-base.js # Shared ESLint config
├── tsconfig.base.json # Shared TypeScript base
├── tsconfig.nextjs.json # Next.js-specific extends
├── tsconfig.angular.json # Angular-specific extends
├── tsconfig.rn.json # React Native-specific extends
└── tsconfig.nest.json # NestJS-specific extends
It also wrote the MIGRATION.md explaining what each override was for and why — documentation that was directly useful for the three engineers who joined the project after the migration.
After the migration, our repository looks like this:
industrybuying/
├── apps/
│ ├── storefront/ # Next.js 14
│ ├── ops-dashboard/ # Angular 17
│ ├── mobile/ # React Native 0.73
│ └── api/ # NestJS
├── packages/
│ ├── types/ # @ib/types — shared TypeScript interfaces
│ ├── utils/ # @ib/utils — shared utilities
│ ├── ui-tokens/ # @ib/ui-tokens — design system tokens
│ ├── api-client/ # @ib/api-client — typed API SDK
│ └── config/ # @ib/config — ESLint, TSConfig bases
├── turbo.json
├── package.json
└── pnpm-workspace.yaml
turbo.json Pipeline{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**", "build/**"]
},
"lint": {
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
"cache": true
},
"test:e2e": {
"dependsOn": ["build"],
"cache": false
},
"dev": {
"cache": false,
"persistent": true
},
"deploy": {
"dependsOn": ["build", "test", "lint"],
"cache": false
}
}
}
The "dependsOn": ["^build"] syntax is Turborepo’s way of saying “run all build tasks in dependency packages before running this one.” So when storefront builds, it automatically ensures @ib/types, @ib/utils, and @ib/api-client are built first. No more “why is the type missing?” at 11pm.
@ib/api-client Package — Our Biggest WinThe most impactful package we extracted was @ib/api-client. Previously, each frontend had its own API layer — the storefront had Axios calls, the ops dashboard had Angular’s HttpClient, the mobile app had a custom fetch wrapper. All calling the same API, all slightly inconsistent.
We built @ib/api-client as a framework-agnostic typed SDK that wraps our NestJS endpoints. Claude helped generate the initial client from our NestJS controller decorators:
// packages/api-client/src/products.ts
export class ProductsClient {
constructor(private readonly http: HttpAdapter) {}
async list(params: ProductListParams): Promise<PaginatedResponse<Product>> {
return this.http.get('/products', { params });
}
async getById(id: string): Promise<Product> {
return this.http.get(`/products/${id}`);
}
async updateInventory(
id: string,
payload: UpdateInventoryPayload
): Promise<Product> {
return this.http.patch(`/products/${id}/inventory`, payload);
}
}
The HttpAdapter interface is implemented differently in each app (Axios in Next.js, Angular HttpClient in the dashboard, fetch in React Native), but the business logic is identical. One bug fix in @ib/api-client fixes it in all three frontends simultaneously.
This change alone eliminated a class of bugs where one frontend had a different understanding of an API contract than another.
Before Turborepo, a push to any branch triggered all four pipelines. Every CI run was:
Total: ~22 minutes average, ~45 minutes on a cold cache.
After Turborepo with remote caching enabled:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Run Turborepo pipeline
run: pnpm turbo run lint test build --filter='[HEAD^1]'
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
The --filter='[HEAD^1]' flag tells Turborepo to only run tasks for packages that changed since the last commit. A change to apps/mobile only runs mobile’s lint, test, and build — not Next.js, not Angular, not the API.
Current CI times:
Here’s what we actually came to talk about. The metrics, three months post-migration:
| Metric | Before | After | Change |
|---|---|---|---|
| Avg. feature lead time (dev → production) | 11 days | 4.4 days | −60% |
| CI pipeline duration (average) | 22 min | 5.2 min | −76% |
| Cross-team type conflict bugs per sprint | 6.4 | 0.8 | −87% |
| Time spent on dependency sync (per week) | ~6 hrs | ~45 min | −87% |
| Developer onboarding time (to first PR) | 3 days | 1 day | −66% |
The 60% reduction in feature lead time came from multiple compounding improvements:
The migration was a one-time event, but Claude is now part of our daily engineering workflow. Three months in, here’s where it shows up most:
Code review acceleration. Our PRs are reviewed by a Claude Code instance configured with our monorepo conventions. It catches import-path violations, detects when a shared package should be used instead of a one-off utility, and flags type inconsistencies before human reviewers spend time on them.
New developer ramp-up. When a new engineer joins, Claude Code is their first stop for understanding the codebase. They describe what they want to build, and it generates a correctly-structured implementation that follows our monorepo conventions — with the right @ib/ imports, the right config inheritance, and the right place in the directory tree.
Schema-driven code generation. When we add a new NestJS endpoint, Claude generates the corresponding @ib/api-client method, the TypeScript interface in @ib/types, and the React hook in apps/storefront/hooks/ in one pass. What used to be a 3-file, 3-repo change is now a one-step generation that we review and merge.
I’d be doing you a disservice if I made this sound like a clean migration.
Angular in a pnpm workspace is non-trivial. Angular’s build system has opinions about node_modules resolution that don’t play well with pnpm’s symlinked workspace structure out of the box. We spent a day and a half debugging Module not found errors that turned out to be a missing "public-hoist-pattern" in .npmrc. Claude helped diagnose it, but it was still painful.
React Native Metro and monorepos require a custom resolver. Metro, the React Native bundler, doesn’t understand workspace symlinks by default. We needed a custom metro.config.js:
const { getDefaultConfig } = require('@react-native/metro-config');
const path = require('path');
const root = path.resolve(__dirname, '../..');
const config = getDefaultConfig(__dirname);
config.watchFolders = [root];
config.resolver.nodeModulesPaths = [
path.resolve(__dirname, 'node_modules'),
path.resolve(root, 'node_modules'),
];
module.exports = config;
This took longer to get right than we’d like to admit.
Turborepo’s cache can lie to you. We had two incidents where a cached build served a stale result because we hadn’t correctly declared all output artifacts. The fix was auditing every pipeline entry and being explicit about outputs. But finding those two incidents cost us each about half a day of debugging.
You cannot escape migration day grief. Even with Claude rewriting 97% of import paths correctly, that 3% took human eyes. 180,000 lines of code across four repos. There were late nights.
If you’re running multiple apps and feeling the coordination pain, here’s what I’d tell you based on lived experience:
Start with packages/types and nothing else. Don’t try to extract utilities and UI components and configs all at once. Get your shared types compiling cleanly first. Everything else is easier once types are canonical.
Use Turborepo’s --dry flag obsessively. Before running any pipeline change in production, turbo run build --dry=json will show you exactly what it plans to do and why. This saved us from at least three “why is CI rebuilding everything?” incidents during the migration.
Invest in your dev script. The monorepo is only worth it if local development is good. We have a pnpm dev that spins up all four apps with a single command, hot-reloading across them. Engineers who were skeptical of the migration became converts the first time they edited a shared utility and saw it hot-reload in three apps simultaneously.
Give Claude your full context, not just the immediate problem. The difference between “write a tsconfig for a Next.js app” and “here are our four existing tsconfigs, here’s our Turborepo structure, here’s what each app needs — write a base config and four app-specific extends” is the difference between a generic answer and something you can actually use.
The migration is not the destination. The monorepo is infrastructure. The destination is shipping faster. Keep measuring lead time, CI duration, and bug rates. If the numbers aren’t moving after 90 days, something in the setup isn’t working.
The Turborepo migration was Phase 1. We’re currently working on:
@ib/types as a single source of truth — if an API response no longer matches the canonical type, CI fails before it reaches staging.@ib/* package versioning within the monorepo, so we can publish packages externally when the time comes.The 60% reduction in go-to-market time is meaningful. But the more I look at the numbers, the more I believe the ceiling is higher. When your types are shared, your CI is fast, your imports are correct by construction, and AI can generate cross-stack code that follows your conventions — the constraint on shipping speed stops being technical coordination overhead.
It becomes the quality of your ideas.
The Turborepo migration was the right call. If you’re running a multi-app product with a growing team and you’re still on multiple repositories, the coordination tax you’re paying compounds every sprint. The migration is painful for two weeks and pays back every week after.
What I didn’t expect was how much Claude would change the character of the migration itself — from a grinding manual effort into something that felt more like a directed architectural exercise. The import rewriting script, the type reconciliation, the config unification, the pipeline design: these are tasks that are high-effort, low-creativity, and error-prone when done by hand. They’re exactly what AI-assisted engineering handles well.
More than the monorepo, I think the real unlock was getting our team comfortable with the idea that Claude is a collaborator on infrastructure work, not just a code autocomplete. When your senior engineers stop spending time on mechanical transformation tasks, they spend it on architectural decisions. That’s where the 60% reduction really came from.
If you’re building something similar or have questions about specific parts of the migration, I’m reachable on GitHub and always happy to compare notes.
The numbers in this post are real. The migration happened. The coffee was strong.
— Rohit Mishra, Senior Engineering Manager, IndustryBuying
Published in
IndustryBuying Engineering1.2K followers · Last published Mar 29
Engineering stories, architectural decisions, and hard-won lessons from the team building India's leading B2B e-commerce platform.
Written by
Senior Engineering Manager with 10+ years building distributed systems and cross-platform products. Currently leading engineering at IndustryBuying. Passionate about developer experience, monorepo architecture, and shipping fast without breaking things.
How we rearchitected IndustryBuying's storefront to handle a catalog that doubled every eight months — and the ISR, edge caching, and search lessons that made it possible
A step-by-step account of profiling, layering cache strategies, and the three mistakes we made before we got it right
What happens when 50,000 B2B buyers expect live stock updates on a platform that was built for batch processing
An honest retrospective on six years of accumulated shortcuts, the hidden costs nobody was tracking, and what actually changed when we stopped pretending the debt didn't exist