Skip to main content
Algoramming
HomeAbout
ProjectsBlogsCareersContact
Let's Talk
01Next move

Software that works quietly, every day —

Ready to build something people stick with?

Send the brief — bullet points are fine. We reply within one business day with a plain-English next step. NDA on request.

Start a projectBook a 30-min call
Studio signalAccepting briefs
Reply
≤ 1 business day
Discovery
Free 30-min call
Engagement
Fixed scope or retainer
Timezone overlap
6+ hours, any region
support@algoramming.comDhaka · GMT (UTC+6)
Reply in one business day
NDA on request
Plain-English scoping note
Senior team, end-to-end
Algoramming Systems Ltd.

An independent product studio in Dhaka, designing and engineering high-performing custom software, mobile, and web apps for ambitious teams worldwide.

Innovation in every step

Company

  • About us
  • Services
  • Projects
  • Blogs
  • Careers
  • Contact

Services

  • Custom software
  • Mobile apps
  • Web applications
  • UI/UX design
  • Product consultation
  • Tech partnership

Get in touch

  • House #12, Road #02, Dag #1677
    Merul Badda, Anandanagar
    Dhaka-1212, Bangladesh
    Open in Maps →
  • +880 1400 629698
  • support@algoramming.com

New posts, in your inbox

We send a short email whenever we publish a new field note or ship a studio update. No fixed schedule, no filler, unsubscribe in one click.

Working with teams in

  • DhakaBangladeshBST
  • DubaiUAEGST
  • DohaQatarAST
  • MansfieldUSAEST
  • Mexico CityMexicoCST
  • MonfalconeItalyCET
  • MelbourneAustraliaAEST
  • VarnaBulgariaEET

© 2026 Algoramming Systems Ltd.All rights reserved.

Privacy PolicyTerms and ConditionsSitemap
Home/Field notes/SPA to Next.js App Router Performance Audit
Field note

SPA to Next.js App Router Performance Audit

We migrated a heavy analytics dashboard from a client-side SPA to Next.js App Router. Hereis the deep-dive performance audit of bundle sizes and Core Web Vitals.

Written by
Algoramming Systems Ltd.
May 21, 202614 min read3,007 words
  • react
  • nextjs
  • performance
  • web-development
SPA to Next.js App Router Performance Audit

Moving From SPA to Next.js App Router: A Performance Audit

Youruser opens their browser, logs into your dashboard, and watches a blank screen for four seconds. A spinning wheel appears, followedby a sudden flash of unformatted layouts as 1.8 megabytes of JavaScript hydrates the viewport. For years, thiswas the accepted cost of building complex, highly interactive software. We packaged our entire application logic, routing, state management, utilitylibraries, and UI components into a single massive client-side bundle.

While Single Page Applications (SPAs) provided snappytransitions once loaded, the initial boot time was a major performance bottleneck. As search engines prioritized user experience metrics and users demanded faster loadtimes on mobile devices, the limitations of client-side rendering became impossible to ignore.

To address these challenges,we recently undertook a complete migration of a heavy, data-heavy enterprise analytics dashboard. We moved the application from a Vite-based client-side SPA to the Next.js App Router. This performance audit examines the real-world results ofthat migration. We will look at the bundle size reductions, analyze the changes in Core Web Vitals, and review the architectural decisions thatmade these improvements possible.


The Baseline: Anatomy of a Heavy SPA Dashboard

Before looking at the migration metrics, we must understand the starting point. The original application was a classic client-side SPA built with React 18, Vite, and React Router. It served as an administrative and analytics dashboard for a high-volume e-commerce platform.The dashboard featured interactive data tables with complex filtering, real-world charting with multiple visualization types, markdown generation for reports, and aheavy settings panel. To power these features, the client-side bundle pulled in several large third-party dependencies:

  • Recharts for rendering interactive area, bar, and scatter charts.
  • TanStack Table for managingcomplex grid states, column sorting, and pagination.
  • date-fns for date manipulation, timezone conversions, and formatting.
  • Lodash for deep object manipulation and utility functions.
  • Zustand for global client-side state management.
  • Axios for handling HTTP requests to a separateREST API.

Because it was an SPA, every single one of these libraries had to be downloaded, parsed, and executedby the browser before the user could see the first meaningful piece of data. Even with code-splitting applied at the route level,the shared vendor bundle remained incredibly large.

// Typical SPA entry point (App.tsx) showing themassive import tree
import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DashboardLayout} from "./components/Layout";
import { AnalyticsView } from "./views/AnalyticsView"; // Imports Recharts, date-fns, lodash
import { SettingsView } from "./views/SettingsView"; // Imports heavy form utilities
import {ReportGenerator } from "./views/ReportGenerator"; // Imports markdown parser

const queryClient = new QueryClient();

exportfunction App() {
  return (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter><Routes>
          <Route path="/" element={<DashboardLayout />}>
            <Route index element={<AnalyticsView />}/>
            <Route path="reports" element={<ReportGenerator />} />
            <Route path="settings" element={<SettingsView />} />
          </Route>
        </Routes>
      </BrowserRouter>
    </QueryClientProvider>
  );
}

This architecture created a fundamental performance bottleneck. The browser spent valuable main-thread cycles downloading and compiling code that the user might not even interact with during their session.


The Core WebVitals Bottleneck on Client-Side Rendering

To establish a clear baseline, we measured the performance of our SPA using Lighthouse andChrome User Experience Reports (CrUX). The results highlighted the clear limitations of the client-side rendering approach.

MetricSPA Baseline (Mobile)    Target Budget
----------------------------------------------------------------------
Largest Contentful Paint (LCP)  4.8 seconds              < 2.5 seconds
Interaction to Next Paint (INP) 320 milliseconds< 200 milliseconds
Cumulative Layout Shift (CLS)   0.18                     < 0.10
TotalBlocking Time (TBT)       850 milliseconds         < 150 milliseconds

The Largest Contentful Paint (LCP) was poor. Because the page was a blank HTML shell, the browser had to download the JavaScript bundle, parseit, execute it, trigger an API request to fetch the dashboard data, wait for the response, and then render theDOM elements. This multi-step waterfall delayed the LCP significantly.

Interaction to Next Paint (INP) was alsosluggish. Because the main thread was busy parsing large libraries and hydrating the entire page layout, any click or input event duringthe first few seconds of load time was delayed.

Cumulative Layout Shift (CLS) suffered because of the loading state transitions. The skeleton screens and spinner components did not match the exact dimensions of the final loaded charts and tables. When the data finallyarrived, elements shifted down the page, creating a jarring user experience.


The Architectural Shift: Server Components as the DefaultThe nextjs app router migration introduces a fundamentally different approach to rendering React applications. Instead of sending all components to the browserto be rendered on the client, Next.js utilizes React Server Components (RSC) by default.

In this architecture,components are executed on the server. The server generates a lightweight, serialized JSON description of the UI, along with the renderedHTML. The browser receives this HTML immediately, allowing it to display content without waiting for JavaScript to load and execute.```javascript // app/analytics/page.tsx (React Server Component) import { fetchAnalyticsData } from "@/lib/api"; import { FinancialSummary } from "@/components/FinancialSummary"; import { InteractiveChart } from"@/components/InteractiveChart";

export const revalidate = 300; // Cache for 5 minutes

export defaultasync function AnalyticsPage() { // Data is fetched directly on the server, close to the database const data =await fetchAnalyticsData();

return (

Analytics Dashboard

Real-time performance metrics

{/* Rendered on server, zero client-side JS needed for this component */}

  {/* Interactive elements are isolated as Client Components */}
  <InteractiveChart initialData={data.chartPoints} />
</div>

); }


By adopting this server-first approach, we changedthe relationship between our code and the user's browser. We no longer had to ship heavy layout components, utility files,or formatting helpers to the client. They stayed on the server, executing in a fast, controlled environment.

---

##Step-by-Step Migration Strategy for Dashboards

Migrating a production-grade dashboard application with hundreds of components cannot happenovernight. We followed a structured four-stage process to transition from our Vite SPA to the Next.js App Router without breaking existingfunctionality.

### 1. Dependency Audit and Compatibility Check
We scanned our packages to identify libraries that relied on client-side globals like `window`, `document`, or browser history APIs. Libraries like Recharts required special handling, asthey use browser-specific APIs to measure container dimensions.

### 2. Setting Up the Next.js Wrapper and SharedLayouts
We configured a new Next.js project alongside the existing SPA. We established the root layout, global styles, and navigationsystems. This allowed us to build the basic structural scaffolding before moving individual views.

### 3. Identifying and Isolating Interactive IslandsWe analyzed our views and split them into static presentation components and interactive components. Anything requiring hooks like `useState`, `useRef`, or `useEffect` was moved into isolated files marked with the `"use client"` directive.

### 4. Incremental Route Migration
Instead of a big-bang release, we migrated one route at a time. We started with the simplestpages, such as the Settings and Profile views, before tackling the high-traffic, data-heavy Analytics and Reporting sections.

[ Vite SPA ] ---> [ Audit Dependencies ] ---> [ Root Layout in Next.js ]| [ Live Release ] <-- [ Route Migration ] <-- [ Isolate Client Components ]


This incremental approach ensured thatour development team could test and validate performance gains at each step of the journey, reducing the risk of regression.

---

##Bundle Size Breakdown: Client vs Server Allocation

The primary goal of our performance audit was to analyze changes in the client-side bundlesize. In a traditional SPA, everything is a client component. In the Next.js App Router, we can keepthe vast majority of our rendering logic on the server.

To measure this, we used `@next/bundle-analyzer` and compared its outputs against our original Vite production build. The results showed a major reduction in JavaScript sent to the browser.

Bundle Category Vite SPA (Client JS) Next.js App Router (Client JS)------------------------------------------------------------------------------ Framework / Core 142 KB 84 KB Shared Utilities 210 KB 18 KB UI Components 345 KB 42 KB Charts & Visualizations480 KB 185 KB (Lazy Loaded) Data Fetching / State 125 KB 12 KB Total Initial JS 1,302 KB 341 KB


How did we achieve a73% reduction in initial client-side JavaScript?

First, we eliminated the need for client-side routinglibraries and general data-fetching engines like Axios. Next.js handles routing out of the box, and we shifted ourAPI communications to native server-side `fetch` calls. 

Second, our heavy utility libraries like `date-fns` and`lodash` were kept entirely on the server. When a server component formats a timestamp or filters an array, the browseronly receives the final rendered string or the filtered UI list. The library code itself never travels over the network.

Third, we optimizedour charting implementation. Instead of loading the entire charting library during the initial page load, we isolated the chart into a client component andloaded it dynamically using `next/dynamic`. The chart library is now only downloaded when the user actually scrolls to the visualizationsection of the screen.

```javascript
// components/InteractiveChart.tsx
"use client";

import dynamic from"next/dynamic";
import { Skeleton } from "./Skeleton";

// Dynamically import the heavy charting component only on the clientconst LazyChart = dynamic(() => import("./RealChartComponent"), {
  ssr: false,
  loading: ()=> <Skeleton className="h-80 w-full" />,
});

interface ChartProps {
  initialData:Array<{ date: string; value: number }>;
}

export function InteractiveChart({ initialData }: ChartProps) {return (
    <div className="rounded-lg border p-6 bg-white">
      <h3 className="text-lg font-medium mb-4">Revenue Trend</h3>
      <div className="h-80"><LazyChart data={initialData} />
      </div>
    </div>
  );
}
```---

## Core Web Vitals Before and After: The Real Numbers

With the bundle size significantly reduced, we re-ran our performance audits. The real-world impact of the react server components performance improvements was immediate and measurable across all primaryCore Web Vitals metrics.

Metric SPA Baseline Next.js App Router Improvement

Largest Contentful Paint (LCP) 4.8s 1.4s - 70.8% Interaction to Next Paint (INP) 320ms 85ms -73.4% Cumulative Layout Shift (CLS) 0.18 0.03- 83.3% Total Blocking Time (TBT) 850ms 95ms -88.8% Speed Index 3.9s 1.1s - 71.7%


Let us dissect why these improvements were so dramatic.

Our Largest Contentful Paint dropped from 4.8 seconds to 1.4 seconds. In the SPA model, the browser could not render the LCP element (usuallya large hero metric or a main chart) until the entire JS bundle loaded and executed. With Next.js, theserver renders the HTML skeleton containing our structured layouts and text elements instantly. The user sees a fully formed page layout almost immediately.Interaction to Next Paint fell to 85 milliseconds, comfortably below the 200ms threshold for a "good" rating. Because the client-side JavaScript execution queue was no longer clogged with megabytes of framework initialization code, thebrowser main thread remained free to handle user interactions instantly.

Cumulative Layout Shift was resolved by utilizing structured React Server Components alongside Next.js streaming. By streaming slow data-fetching components using React `Suspense`, we loaded the shell of the page firstand defined exact layout boundaries for the incoming data components. This prevented sudden layout shifts when the data arrived.

```javascript// app/dashboard/page.tsx
import { Suspense } from "react";
import { SkeletonGrid} from "@/components/SkeletonGrid";
import { SlowMetricWidget } from "@/components/SlowMetricWidget";

exportdefault function DashboardPage() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      <div className="md:col-span-2"><h2 className="text-xl font-bold">Main Dashboard</h2>
      </div>
      
      {/*This component fetches data from a slow external legacy API.
        By wrapping it in Suspense, we preventthe slow API from 
        blocking the initial render of the rest of the dashboard.
      */}
      <Suspensefallback={<SkeletonGrid items={3} />}>
        <SlowMetricWidget />
      </Suspense></div>
  );
}

Handling State and Interactive Islands

One of the main challenges duringa nextjs app router migration is shifting from a global client-side state model (like a massive Redux or Zustand store) toa hybrid state model.

In a traditional SPA, we often put all fetched data into a global store. This allowed anycomponent in the application tree to access and modify the data. However, this approach forced every component to be a client component, negating the benefits of React Server Components.

To solve this, we adopted the "islands of interactivity" pattern.We re-evaluated our state requirements and categorized state into three distinct buckets:

  1. Server State: Thisis the data fetched from our database or API. We no longer store this in a global client state manager. Instead, we fetch itdirectly in Server Components and pass it down as read-only props. If the data needs to be refreshed, we triggera router refresh or use Server Actions.
  2. Local UI State: State that controls simple UI behaviors, such as whethera sidebar is open or a dropdown is expanded. We handle this using local React useState hooks inside localized Client Components.3. Shared Interactive State: Complex state shared across multiple components, such as active filters, date ranges, orsearch queries. Instead of an in-memory client-side store, we moved this state into the URL query parameters.By storing interactive state in the URL, we solved several common dashboard architectural problems:
  • Deep Linking: Users can nowcopy the dashboard URL and share it with teammates, who will see the exact same filters, date ranges, and views.* Server Compatibility: Server Components can read the URL search parameters directly from the searchParams prop,allowing them to fetch filtered data on the server.
  • Zero Client Overhead: We removed global state providers and contextwrappers, further reducing the client-side bundle size.

Here is an example of how we implemented URL-driven state forour dashboard filters:

// components/DashboardFilters.tsx
"use client";

import { usePathname, useRouter, useSearchParams } from "next/navigation";

export function DashboardFilters() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();function handleFilterChange(term: string) {
    const params = new URLSearchParams(searchParams);
    if(term) {
      params.set("range", term);
    } else {
      params.delete("range");}
    // Updates the URL without triggering a full page reload,
    // prompting Next.js tofetch updated data on the server.
    replace(`${pathname}?${params.toString()}`);
  }

  constcurrentRange = searchParams.get("range") || "7d";

  return (
    <div className="flex gap-2">
      {["7d", "30d", "90d"].map((range) => (
        <button
          key={range}
          onClick={() => handleFilterChange(range)}
          className={`px-4 py-2 rounded-md text-sm font-medium ${
            currentRange ===range
              ? "bg-blue-600 text-white"
              : "bg-gray-100 text-gray-700 hover:bg-gray-200"
          }`}>
          Last {range}
        </button>
      ))}
    </div>
  );
}```

---

## Data Fetching Patterns: Bypassing the Client API Layer

In our original SPA, wehad to maintain a complex API orchestration layer. The client application had to authenticate requests, attach bearer tokens, handle token expiration, manageretry logic, and parse response payloads. This client-side processing added significant overhead.

When we migrated to the Next.js App Router, we refactored our data fetching patterns to leverage server-side direct execution. 

```[ SPA Client ] ---> [ Public API Gateway ] ---> [ Database ] (Slow, high latency)[ NextJS Server ] ---> [ Secure Private VPC ] ---> [ Database ] (Fast, low latency)

Because Server Components run in asecure, server-side environment, they can communicate directly with internal databases, Redis caches, or private microservices. This providesseveral major performance advantages:

  • Reduced Network Latency: Server-to-database connections typically operate within thesame cloud data center, resulting in sub-millisecond query response times. Client-to-API calls, on the other hand, are subject to mobile network latency and geographic distance.
  • Secure API Keys: We no longer expose sensitiveAPI keys or connection strings to the browser. All database credentials and private tokens remain securely stored on our server environment. *Reduced Payload Size: Instead of downloading a massive, nested JSON payload from a public API endpoint and filtering it in the browser, we run the database queries and calculations on the server. We only send the final, formatted data to the client, savingvaluable network bandwidth.

For example, our reporting module required analyzing hundreds of customer feedback rows to generate a summary. In theSPA, we downloaded all raw feedback items to the client and computed the statistics. In Next.js, we do thiscalculation on the server, sending only the final summary metrics to the browser.


Common Migration Pitfalls and How toAvoid Them

While the performance benefits of our nextjs app router migration were clear, the process was not without hurdles. Weencountered several technical challenges that required careful planning to overcome.

1. Third-Party Libraries Without "use client"Many older React libraries do not include the "use client" directive. When imported directly into a Server Component, they throwruntime compilation errors. To resolve this, you can wrap the third-party component in a local file marked with "use client" at the top, and import your local wrapper instead.

2. Context Provider Wrapping

If your application relieson shared context providers (for example, a theme provider or authentication context), placing them at the root of your App Router structure can accidentallyforce the entire application tree to render as Client Components. To avoid this, isolate your context providers into a dedicated client-wrappercomponent and import that wrapper inside your root server layout.

// app/providers.tsx
"useclient";

import { ThemeProvider } from "your-theme-library";
import { AuthProvider } from "@/context/AuthContext";

export function Providers({ children }: { children: React.ReactNode }) {
  return (<AuthProvider>
      <ThemeProvider>{children}</ThemeProvider>
    </AuthProvider>
  );
}// app/layout.tsx
import { Providers } from "./providers";

export default function RootLayout({ children }: {children: React.ReactNode }) {
  return (
    <html lang="en">
      <body><Providers>{children}</Providers>
      </body>
    </html>
  );
}

###3. Serialization Errors When passing data from a Server Component to a Client Component, the data must be serializable. This meansyou cannot pass complex objects like JavaScript class instances, Map/Set structures, or raw Date objects directly. Ensure your server-side database queries format values into clean, primitive JSON objects before passing them down the component tree.


**Key takeaways**
  • Massive Bundle Reductions: Shifting layout, data fetching, and utility libraries toReact Server Components reduced our initial client-side JavaScript bundle size by 73%.
  • Improved Initial Load Times: Moving rendering logic to the server dropped our Largest Contentful Paint (LCP) from 4.8 seconds to1.4 seconds, providing an immediate visual experience.
  • Streamlined Data Fetching: Executing databasequeries and API calls on the server eliminated client-side network waterfalls, secured API tokens, and reduced payload sizes.* Better Main Thread Performance: Isolating complex client dependencies (like interactive charting libraries) through dynamic imports reduced Total Blocking Time(TBT) by 88.8%.

Conclusion

Migrating a heavy, complexdashboard from a client-side SPA to the Next.js App Router represents a major architectural shift. By moving rendering and datafetching to the server, we transformed our frontend from a heavy, slow-loading application into a fast, highly optimized interface. Theperformance audit confirms that React Server Components are not just a minor improvement; they fundamentally change how we build high-performance web applications.

While the migration process requires careful planning, deep-dependency auditing, and a shift in how we manage application state, the performanceand user experience gains are well worth the effort. Our users no longer watch a blank screen or experience sluggish interactions. Instead, they enjoy an app that loads instantly and responds immediately.

If you are planning a migration project like this or want to optimizeyour application's performance, we are happy to talk it through.

Share this
Reply to this note
Working on something?

Have a project in mind?

We design and engineer software, mobile, and web products end-to-end. Send the brief, we will reply within one business day.

Start a project
New posts, in your inbox

Be first to read the next note.

We send a short email whenever we publish a new field note or ship a studio update. No fixed schedule, no filler.

Unsubscribe in one click. We never share your address.

Keep reading

More field notes like this.

All posts
Anatomy of an API Leak:Incident Response and Recovery01 · Related
May 21, 2026·15 min

Anatomy of an API Leak:Incident Response and Recovery

A step-by-step engineering case study of an API credential exposure and how modern product teams automate secret detection and rotation.

Read post
Beyond OpenAI API: Building Local LLM Pipelines for Privacy02 · Related
May 29, 2026·1 min

Beyond OpenAI API: Building Local LLM Pipelines for Privacy

Beyond OpenAI API: Building Local LLM Pipelines for Privacy Sending customer data to a third-party APIis a risk that many startups can no longer afford to take. Whether you are handling medical…

Read post
Why Product-Minded Engineers Outpace Pure Coders03 · Related
May 21, 2026·12 min

Why Product-Minded Engineers Outpace Pure Coders

Discover why developers who combine clean code with product thinking and UI/UX empathy rise fasterto technical leadership positions.

Read post
Liked this note?

Bring us a problem, not just a brief.

We will reply in plain English within one business day, NDA on request. Discovery call is free.

Start a conversationOr browse more field notes