From 32 to 99: What a Next.js Upgrade Did for One App

Next.js has evolved quickly since its early days, and each major release has brought meaningful performance gains. To quantify that progress, the Vercel team took a demo app originally built with Next.js 8, Three.js, Express, MongoDB, Mongoose, Passport.js, and Stripe Elements—a fully functional storefront where users can browse and purchase 3D models—and upgraded it to Next.js 12. The result: Core Web Vitals improved dramatically, and the app's average performance score jumped from 32 to 99.

Here's a breakdown of the specific features that made the biggest difference.

Static Data Fetching Replaces Server Round-Trips

The original app maintained a separate backend folder with a custom Express server exposing three endpoints: /api/checkout for payments, /api/get-products for the store listing, and /api/get-product/:id for individual model details. When a user navigated to the store page, getInitialProps fired off a request to fetch the product data, meaning the page wasn't visible until that request resolved—a slow experience on poor connections.

Next.js 9 introduced getStaticProps, which fetches data at build time instead of request time. This function always runs server-side, so any code inside it is excluded from the client-side JavaScript bundle. That means server-side logic can live directly in the function without bloating what's sent to the browser.

Combined with the <Link /> component, the gain compounds. Next.js prefetches linked pages as soon as the link enters the viewport. The user doesn't need to click before the necessary store.js and store.json files are fetched—they're already in place, making navigation nearly instant.

Images: Smaller Formats, Less CPU

The thumbnail loading time on the store page also dropped dramatically. The cause wasn't image editing—it was swapping the native <img /> tag for the <Image /> component introduced in Next.js 10.

Three things changed:

  • Next-gen format: The component serves webp, which is 25-35% smaller than JPEGs of the same quality. One car model's image dropped from 1.3MB to 16.6kB, a reduction of 98.75%.
  • Lazy loading: The old implementation fetched all 12 model images upfront. The <Image /> component fetches each image only when it enters the viewport.
  • No layout shift: The component is explicitly designed to prevent the layout shifting that occurs when images load asynchronously.

Average image loading time went from around 3000ms to roughly 270ms without any manual image optimization.

Dynamic Routes Pre-Render Model Pages

In the original app, clicking a product used query parameters and getInitialProps to fetch the associated data, again forcing the user to wait for an API call before seeing anything. Next.js 9 changed this with file-system-based dynamic routes. The route /model/[id] can be paired with getStaticPaths and getStaticProps to pre-render each model's page statically at build time.

With every product card wrapped in a <Link />, each model page is prefetched when its card becomes visible. Clicking through is instant because there's no data fetch left to do.

API Routes and a Simpler Architecture

Not everything can be static. The payment checkout endpoint must be available at runtime because the data it processes—a unique token generated for the user's card—doesn't exist at build time. Next.js 9's API Routes solved this by letting the endpoint live directly in the /pages folder as a function, eliminating the need for a separate Express server. With the product endpoints replaced by static generation and the checkout route recreated as an API Route, the entire backend folder became unnecessary—the project root could serve both frontend and backend.

User authentication saw the same treatment. Passport.js with custom configuration was replaced by NextAuth.js, which supports over 50 providers and reduces the setup to a few lines in pages/api/[...nextauth].ts.

Dynamic Imports for Non-Critical Components

Not all components need to be in the main JavaScript bundle. The CartSidebar, for example, only appears when the user clicks the cart icon or adds an item—making it invisible on initial load.

Using next/dynamic instructs Next.js to code-split this component into its own chunk, fetched only when triggered. Because CartSidebar was the sole consumer of Stripe's payment libraries, those third-party imports were deferred alongside it. Initial JavaScript payload shrank, and the page loaded faster.

Inline Font Optimization

Since Next.js 10, Automatic Font Optimization inlines font CSS at build time, eliminating an extra round trip for font declaration fetches. The effect was visible immediately: the app's font-loading requests dropped from 4 to 2 purely by upgrading.

Beyond Performance: The Developer Experience

The upgrade delivered developer experience improvements just as tangible as the performance wins.

TypeScript Without Configuration

TypeScript support is built in from Next.js 9. Existing projects just add a tsconfig.json; new ones can run npx create-next-app --ts. Custom setups are no longer required.

The SWC Compiler

Next.js 12 ships a Rust-based compiler built on SWC, which leverages native compilation. For this demo app, build time dropped from roughly 90 seconds to 30 seconds by upgrading versions alone.

Faster React Fast Refresh

Fast Refresh, enabled by default since version 9.4, provides near-instant feedback on React component edits without losing state. The SWC compiler in Next.js 12 improved refresh rates further, making them 3x faster than earlier versions.

The upgrade path demonstrates that Next.js's improvements are designed for incremental adoption—the app's performance and architecture improved significantly with minimal changes, all while maintaining backward compatibility. The full diff is available for review to see exactly what changed.