Turbopack Production Ready: My Migration Experience

6 min read1096 words

Our Next.js build took 8 minutes. After migrating to Turbopack in Next.js 16, it takes 2 minutes and 40 seconds. That's a 3x improvement with zero code changes. But the migration wasn't entirely smooth—we hit three significant issues. Here's the complete story.

Why Turbopack Matters

Turbopack replaces Webpack as Next.js's bundler. Written in Rust, it parallelizes work more efficiently and caches aggressively. The performance gains are substantial:

| Metric | Webpack | Turbopack | Improvement | |--------|---------|-----------|-------------| | Cold build | 8m 12s | 2m 40s | 3x faster | | Dev startup | 45s | 8s | 5.6x faster | | HMR update | 2.1s | 180ms | 11.7x faster | | Memory usage | 4.2GB | 1.8GB | 2.3x less |

These numbers are from our production application: 450 components, 200 routes, 180 dependencies.

The Migration Path

Next.js 16 makes Turbopack the default. If you're upgrading, you're automatically on Turbopack unless you opt out:

// next.config.js - opt out if needed
module.exports = {
  turbo: false, // Falls back to Webpack
};

For existing projects, the upgrade command handles most transitions:

npx next@latest upgrade

Issue 1: Custom Webpack Loaders

Our first build failed immediately:

Error: Cannot find module './svgr-loader'

We used @svgr/webpack to import SVGs as React components:

// Old next.config.js
module.exports = {
  webpack(config) {
    config.module.rules.push({
      test: /\.svg$/,
      use: ['@svgr/webpack'],
    });
    return config;
  },
};

Turbopack doesn't support Webpack loaders. The solution was migrating SVGs to a different pattern:

// Before: SVGR loader
import Logo from './logo.svg';
<Logo className="h-8 w-8" />
 
// After: React component
// components/icons/Logo.tsx
export function Logo({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="currentColor">
      <path d="..." />
    </svg>
  );
}
 
// Or use next/image for static SVGs
import Image from 'next/image';
<Image src="/logo.svg" alt="Logo" width={32} height={32} />

We created a script to automate the conversion:

// scripts/convert-svgs.js
const fs = require('fs');
const path = require('path');
const { transform } = require('@svgr/core');
 
const svgDir = './public/icons';
const outputDir = './components/icons';
 
fs.readdirSync(svgDir).forEach(async (file) => {
  if (!file.endsWith('.svg')) return;
 
  const svg = fs.readFileSync(path.join(svgDir, file), 'utf8');
  const componentName = file.replace('.svg', '').replace(/-./g, x => x[1].toUpperCase());
 
  const component = await transform(svg, {
    typescript: true,
    plugins: ['@svgr/plugin-jsx'],
  });
 
  fs.writeFileSync(
    path.join(outputDir, `${componentName}.tsx`),
    component
  );
});

Issue 2: CSS Module Composition

We used CSS Module composition extensively:

/* styles/buttons.module.css */
.base {
  padding: 8px 16px;
  border-radius: 4px;
}
 
.primary {
  composes: base;
  background: blue;
  color: white;
}

Turbopack initially had issues with complex composes chains. The fix was simplifying composition:

/* Simplified approach */
.base {
  padding: 8px 16px;
  border-radius: 4px;
}
 
.primary {
  padding: 8px 16px;
  border-radius: 4px;
  background: blue;
  color: white;
}

Or better, we migrated shared styles to Tailwind utilities:

// Using Tailwind instead of CSS Modules composition
<button className="px-4 py-2 rounded bg-blue-600 text-white">
  Primary Button
</button>

Issue 3: Dynamic Imports with Complex Paths

Our barrel exports caused issues:

// lib/components/index.ts
export { Button } from './Button';
export { Card } from './Card';
export { Modal } from './Modal';
// ... 50 more exports
 
// Usage
const { Modal } = await import('@/lib/components');

Turbopack struggled with tree-shaking these dynamic imports. The solution was direct imports:

// Instead of barrel import
const Modal = await import('@/lib/components/Modal');
 
// Or static import with React.lazy
const Modal = lazy(() => import('@/lib/components/Modal'));

Performance Monitoring

We tracked build performance during migration:

// scripts/build-metrics.js
const { execSync } = require('child_process');
const fs = require('fs');
 
const start = Date.now();
execSync('next build', { stdio: 'inherit' });
const duration = Date.now() - start;
 
const metrics = JSON.parse(
  fs.readFileSync('.next/build-metrics.json', 'utf8') || '{}'
);
 
console.log({
  totalDuration: `${(duration / 1000).toFixed(1)}s`,
  ...metrics,
});

Incremental Migration Strategy

We didn't migrate everything at once:

Week 1: Development Mode Only

module.exports = {
  // Use Turbopack only in development
  turbo: process.env.NODE_ENV === 'development',
};

This let developers experience Turbopack's fast HMR while production builds remained on Webpack.

Week 2: Fix Development Issues

We addressed SVG imports and CSS Module issues that appeared during development.

Week 3: Production Builds

With development stable, we enabled Turbopack for production:

module.exports = {
  turbo: true,
};

Week 4: Monitoring and Optimization

We monitored production deploys for any runtime issues and optimized remaining slow paths.

Turbopack-Specific Optimizations

Once on Turbopack, we leveraged its features:

Persistent Caching

Turbopack caches build artifacts more aggressively. Our CI benefited from cache reuse:

# .github/workflows/build.yml
- name: Cache Turbopack
  uses: actions/cache@v3
  with:
    path: |
      .next/cache
      node_modules/.cache/turbopack
    key: turbopack-${{ hashFiles('**/package-lock.json') }}-${{ github.sha }}
    restore-keys: |
      turbopack-${{ hashFiles('**/package-lock.json') }}-

Parallel Processing

Turbopack parallelizes more work. We ensured our CI had sufficient CPU:

jobs:
  build:
    runs-on: ubuntu-latest-16-cores # More cores = faster builds

Development Experience Improvements

The biggest win was development velocity:

Before (Webpack)

  • Start dev server: 45 seconds
  • Change a component: 2.1 second HMR
  • Change a page: 3.5 second refresh

After (Turbopack)

  • Start dev server: 8 seconds
  • Change a component: 180ms HMR
  • Change a page: 400ms refresh

Developers no longer waited for builds. The feedback loop tightened dramatically.

Compatibility Checklist

Before migrating, verify:

  • [ ] No custom Webpack plugins in next.config.js
  • [ ] No custom Webpack loaders (especially for SVG, GraphQL)
  • [ ] CSS Modules don't use complex composes chains
  • [ ] Dynamic imports use direct paths, not barrel exports
  • [ ] No webpack-specific environment variables

If any apply, plan remediation before enabling Turbopack.

Rollback Plan

Keep a rollback path:

// next.config.js
const useTurbopack = process.env.USE_TURBOPACK !== 'false';
 
module.exports = {
  turbo: useTurbopack,
};
# Quick rollback via environment variable
USE_TURBOPACK=false npm run build

When to Stay on Webpack

Some scenarios still favor Webpack:

  • Heavy reliance on Webpack plugins with no Turbopack equivalent
  • Custom loaders that can't be replaced
  • Monorepos with complex shared configurations
  • Very large legacy codebases where migration cost exceeds benefit

For most Next.js applications, especially greenfield projects, Turbopack is the clear choice.

Results Summary

Three weeks of migration work yielded:

  • 3x faster production builds: 8 minutes → 2.5 minutes
  • 5x faster dev startup: 45 seconds → 8 seconds
  • 10x faster HMR: 2 seconds → 180ms
  • 50% less memory: Better CI performance and cost

The investment paid off within a week through developer productivity gains. Turbopack in Next.js 16 is ready for production—the migration just requires planning for Webpack-specific patterns.