DEV Community

Cover image for How We Cut Slow Responses by 80% Migrating to Next.js App Router
Francesca Milan for Subito

Posted on with Alessandro Grosselle

How We Cut Slow Responses by 80% Migrating to Next.js App Router

Subito is Italy's leading classifieds marketplace, serving millions of users every month. Our ad detail page is one of the most heavily trafficked pages on the platform, handling thousands of requests per minute and playing a critical role in both SEO and revenue.

In this article, we'll walk through how we migrated it from the Next.js Pages Router to the App Router, the challenges we encountered along the way, and how the migration helped reduce slow responses by roughly 80%.

Table of Contents

The Goal

As we always do at Subito, we never migrate for migration's sake. We go in with specific, measurable targets:

  • Faster page loads: leverage React Server Components and HTML streaming
  • Better SEO: improve page speed scores, working alongside our SEO team, faster pages rank better

The migration touched every layer of the stack: Next.js, a custom Express server, Nginx, and Akamai as our CDN. Here's what each step looked like.

Migration Approach: Ship Without Blocking Anyone

The ad detail page is one of the highest-traffic pages on the platform. We couldn't freeze product work for weeks while we rewrote it. So we didn't.

Instead of doing a big-bang rewrite, we incrementally added the App Router tree, server components, data fetching, layouts, alongside the existing Pages Router page.

The key insight was that each new server component was simply a wrapper around the same client component already used by the Pages Router.
There was no duplication: one client component, two consumers.
This meant that any bug fix or product feature added to a client component automatically applied to both the old and new page. Developers working on the Pages Router side were already contributing to the App Router version without knowing it.

This approach gave us two things:

1. Clean component boundaries. We used server components to own data fetching and pass only what client components actually need. The classic prop-drilling waterfall disappeared.

2. A defined Data Access Layer. Following the Next.js recommendation, we centralized all data fetching behind a DAL and used React's cache() to deduplicate calls. When three server components all need the same ad data, the network call fires once.

For sections with heavy data dependencies or secondary content, we combined Suspense with the use() hook to unlock streaming:

// GeneralInfo.tsx
const GeneralInfo = ({ id, ...props }: Props) => {
  const { promise, resolve } = Promise.withResolvers<AsyncData>();

  fetchAdItem(id).then((ad) => {
    const { urn, advertiser } = ad;

    Promise.all([
      fetchShopData(advertiser.shopId, advertiser.type),
      // ...all the other fetches
    ]).then(([shop /* ...other results */]) => {
      resolve({ ad, shop /* ...other props */ });
    });
  });

  return (
    <Suspense fallback={<GeneralInfoSkeleton />}>
      <LazyGeneralInfo loadData={promise} {...props} />
    </Suspense>
  );
};

const LazyGeneralInfo = ({ loadData, ...props }: Props) => {
  const data = use(loadData);
  // ClientGeneralInfo is the same component used by the Pages Router version. No duplication!
  return <ClientGeneralInfo {...data} {...props} />;
};
Enter fullscreen mode Exit fullscreen mode

The skeleton renders immediately while data resolves in the background. Once it arrives, React swaps in the real component, no layout shift, no full-page loading state.

Progressive Rollout via Custom Express Server

We run a custom Express server in front of Next.js. This gave us a clean toggle to route traffic between the old and new page:

// server.ts
server.get(
  adDetailListingPath,
  validateAdDetailListingParams,
  clientHints,
  securityMiddleware,
  (req, res) => {
    if (enableAppRouterForAdDetail(req.query, req.params.category)) {
      return app.render(req, res, '/ad/', req.params);
    }
    return app.render(req, res, '/addetail/', req.params);
  }
);

// utils/rollout.ts
const FORCE_MIGRATED_AD_QP = 'use-ar';
const MIGRATED_CATEGORIES: Array<string> = ['job'];

export const enableAppRouterForAdDetail = (
  query: ParsedQs,
  pathFriendlyCategory?: string
) => {
  return (
    query[FORCE_MIGRATED_AD_QP] === '1' ||
    (pathFriendlyCategory && MIGRATED_CATEGORIES.includes(pathFriendlyCategory))
  );
};
Enter fullscreen mode Exit fullscreen mode

?use-ar=1 forced the new page for internal testing. The category list let us roll out one macro-category at a time in production, starting with Job category and expanding progressively.

Problem 1: HTTP 410 Gone in App Router

The ad detail page is indexed by search engines, so when an ad expires we must return a proper 410 Gone response. A redirect or even a standard 404 Not Found is not sufficient: a genuine 410 clearly signals to crawlers that the page has been permanently removed and should be dropped from the index as quickly as possible.

The App Router, at the time of our migration, had no built-in API for 410. notFound() returns 404. There's no gone().

We wrote a full discovery article on the available workarounds here: How to Return HTTP 410 Gone in Next.js App Router — Two Workarounds

Since we already owned the Express layer, we went with a status code interceptor: Express intercepts the outgoing response before it hits the client and rewrites the status code when the page signals a gone state. No framework hacks, no monkey-patching Next.js internals.

Problem 2: HTML Streaming Behind Nginx and Akamai

This was the trickiest part of the whole migration: during staging tests we noticed our <Suspense> skeletons were invisible. We even forced an artificial 5-second delay on data fetching; the skeleton still never appeared. The page just loaded slowly as a blank screen.

Nginx Buffering

The first fix was disabling response buffering in Nginx, as documented by Next.js:

proxy_buffering off;
Enter fullscreen mode Exit fullscreen mode

With buffering off, the skeleton appeared... but only when hitting the origin server directly. Through the CDN, still nothing.

Akamai Buffering

We shifted our investigation to Akamai. Our initial hypothesis was a caching layer conflict or a compression issue. We disabled caching... no effect.

Before diving deep into Akamai's Property Manager, we opened a support ticket. The answer was clear:

By default, Akamai buffers HTML responses before delivering them to the client. Disabling this behavior requires advanced metadata and cannot be configured through standard Property Manager rules.

Chunked transfer encoding and early flush are not available as standard toggles, but Akamai provided us with a custom behavior that we could apply via Property Manager:

<network:http.buffer-response-v2>off</network:http.buffer-response-v2>
<edgeservices:wco.input-buffer-minimum>0B</edgeservices:wco.input-buffer-minimum>
<edgeservices:wco.request-flush>on</edgeservices:wco.request-flush>
<edgeservices:modify-outgoing-response.chunk-to-client>on</edgeservices:modify-outgoing-response.chunk-to-client>
Enter fullscreen mode Exit fullscreen mode

Once applied, streaming worked end-to-end. Skeletons appeared immediately; content streamed in as data resolved :tada!

Streaming + Caching: What Actually Happens

After enabling the custom behavior, we re-enabled the Akamai cache layer (non-negotiable for a high-traffic site like ours) and observed the following behavior:

  • Cache MISS: The HTML is streamed. No Content-Length header in the response, the browser receives chunks progressively.
  • Cache HIT: The full HTML is returned at once with a Content-Length header (e.g., 28315). Streaming doesn't apply, but the page is already fully built so it doesn't need to be.

This is the correct behavior. Cached responses are fast regardless; streaming only matters for the cold path.

Rollout and SEO Monitoring

We rolled out in two distinct phase, deliberately separated, not just for technical safety, but for SEO safety.

Phase 1 was controlled at the Express layer: migrate to App Router, streaming off. We started with a single category (Job), monitored carefully, then expanded one macro-category per cycle until all traffic was on the App Router.

Only once the migration was stable did we consider enabling HTML streaming. We were genuinely concerned about the SEO implications: would Google's crawler handle a chunked, incrementally-delivered HTML response correctly? Would it see the full page content, or just whatever arrived in the first chunk? We didn't want to find out by watching rankings drop.

Phase 2 enabled HTML streaming: we started with the Videogiochi (Video Games) category, using a targeted rule in Akamai Property Manager:

If Path matches one of: /vi/*, *.htm
And Path matches one of: /videogiochi/*
Enter fullscreen mode Exit fullscreen mode

Our SEO team played a key role throughout the rollout: before enabling streaming in a new category, we monitored its SEO metrics for roughly two weeks, ensuring there were no unexpected impacts on crawling, indexing, or search performance. Only after the category showed stable results did we move on to the next one.

How Much Effort Did This Migration Require?

Surprisingly little!
The migration was carried out primarily by a single developer, working on it incrementally between regular feature deliveries rather than as a dedicated full-time initiative.

A significant enabler was Claude Code, which we used extensively throughout the project. It helped with planning, architectural exploration, implementation details, and routine development tasks, allowing us to move faster while keeping the migration manageable for a very small team.

Results

Response Times

The Grafana graph below shows the percentage of responses taking more than 250ms. Before the migration (up to ~03/25), the figure sat consistently between 25% and 40%, with spikes reaching 55%.
After the rollout, it collapsed to near zero and stayed there.

SEO Performance

The data below shows Fast URLs (< 500ms) across categories:

The improvements are significant across the board, with Video Games showing the strongest gain at nearly 30%.

Conclusions

The migration itself wasn't the hardest part: Next.js made it possible to run Pages Router and App Router side-by-side, allowing us to migrate incrementally without stopping feature development.

The real challenge was making the entire delivery chain work together: React Server Components, streaming, Express, Nginx, Akamai, observability, and SEO requirements.

Only when all those pieces aligned did we unlock the performance improvements we were aiming for.

A few takeaways for teams considering a similar migration:

  • Parallel routing lets you migrate without a freeze. Pages Router and App Router coexist in the same Next.js project. Use that.
  • A custom server layer is an asset. Express gave us per-request routing control, progressive rollout toggles, and the ability to solve framework gaps (like 410 status codes) without waiting for upstream fixes.
  • HTML streaming requires work at every layer. Nginx buffering, CDN buffering, and chunked transfer encoding all need explicit configuration. Don't assume streaming works end-to-end just because your Next.js code is correct.
  • End-to-end and visual regression tests caught regressions early. Before touching production we had confidence from automated test coverage.

Top comments (0)