Why this matters
Long frontend builds block developer feedback loops and increase CI costs. The Railway post about moving off Next.js is a timely example: they saw a dramatic build-time win when they chose a different stack for their frontend. This guide helps you make the same decision pragmatically: measure, optimize, and — when necessary — migrate with minimal risk.
Quick decision checklist
- Measure current build time and developer feedback time (local + CI).
- Profile your bundle and identify slow phases: compilation, webpack, image optimization, SSR-build, third-party libs, or monorepo overhead.
- Try targeted optimizations first (cache, analyzer, code-splitting, fewer server-side pages).
- If optimization won't reach your target or Next.js features are unused/replaceable, evaluate a migration to a faster build tool (Vite, esbuild-based bundlers, or frameworks like Remix/Astro).
How to measure and profile build time
Start with simple, reproducible measurements. Capture local and CI numbers and break the build into phases.
- Measure wall-clock:
time npm run build(or the CI step duration). - Profile bundles with bundle-analyzer to find heavy dependencies.
Enable bundle analysis in Next.js (example)
Install and add the analyzer plugin to identify large modules and duplicate copies of libraries.
npm install --save-dev @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
reactStrictMode: true,
});
// Run with ANALYZE=true npm run buildCommon Next.js build bottlenecks and quick fixes
- Large client bundles: use dynamic imports, review vendor chunking, and remove duplicated libs.
- Server-side builds: limit pages using getServerSideProps; convert static pages to SSG where possible.
- Image optimization: Next.js Image processing can slow builds — consider delegating to a CDN or pre-optimizing images.
- Monorepos & dependencies: use pnpm workspace, hoist carefully, and cache package manager store in CI.
CI caching and incremental build tips
Many build-time wins come from smart caching and incremental tooling. Cache package manager files and build caches between runs.
Example: GitHub Actions caching snippet
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- name: Restore .next cache
uses: actions/cache@v4
with:
path: .next/cache
key: ${{ runner.os }}-next-cache-${{ hashFiles('**/package-lock.json') }}
- name: Install
run: pnpm install
- name: Build
run: pnpm buildAdjust keys to include lockfiles and major env vars. For pnpm, caching the store ("~/.pnpm-store") and the workspace node_modules layout helps restores.
When migration is the right call
Optimize first. Migrate if you've hit limits (e.g., webpack build time can't be reduced sufficiently, or you need a fresh architecture with fast incremental rebuilds). Typical migration targets are Vite (dev + build speed), esbuild-based toolchains, or frameworks like Remix/Astro that favor faster bundlers.
Tradeoffs to weigh
- Loss of built-in Next features: image optimization, automatic SSR routing, and some middleware behaviors will need replacement.
- Integration cost: routing, auth, and server-side rendering must be reimplemented or swapped for compatible libraries.
- Long-term maintenance: simpler build tools often reduce engineering overhead, but you may need new infra for edge/CDN functions.
Migration pattern: progressive decoupling
- Extract static portions first (marketing pages) and serve via SSG or a separate Vite app.
- Move client-only pages to a Vite-built SPA shipped under the same domain (reverse proxy or subpath).
- Replace SSR routes last; implement server endpoints in a dedicated server (Node/Fastify) or edge functions and point the client to them.
Example: minimal Vite React app scripts
{
"name": "frontend",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5173"
}
}
// vite.config.ts (minimal)
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
target: 'es2020',
cssCodeSplit: true,
sourcemap: false
}
})Practical migration checklist
- Inventory Next.js features in use (Image, Middleware, getServerSideProps, API routes).
- Map replacements: static hosting/CDN for images, edge functions or server for SSR, dedicated APIs for API routes.
- Create a proof-of-concept: migrate a small set of pages to a Vite app and measure build times and developer experience.
- Plan a staged rollout with feature flags and proxy rules to route traffic between old and new frontends.
- Keep CI parallel: run full Next.js build only when necessary; otherwise run targeted builds for changed packages (use path filters).
Real-world tradeoffs and examples
Railway’s move was motivated by build-time reduction for developer productivity. Your mileage will vary: if your app relies heavily on Next.js-only features and SSR everywhere, a migration could increase runtime complexity. For predominantly static or client-rendered apps, Vite/esbuild often yields immediate wins.
Conclusion
Don’t switch frameworks just for hype. Measure first, apply targeted optimizations, and then evaluate migration as a pragmatic path if build times remain a bottleneck. Use progressive decoupling to reduce risk and prioritize caching and incremental builds on CI. When you migrate, focus on small, measurable wins (reduced CI time, faster local reloads) and validate before committing to a full rewrite.
Further reading
Was this helpful?
Share this post
Comments (0)
Want to join the conversation?
Log in or sign up to leave a comment and share your thoughts.
Log in to Comment