Optimizing React Server Components and the Next.js App Router for a Fast E-commerce Storefront
Bài này có bản tiếng Việt. Đọc bản tiếng Việt →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Our storefront sells tours and tickets. Two requirements pull in opposite directions: content has to be pre-rendered for search engines, while the cart and the booking form have to be interactive immediately. Pick one approach and you lose the other.
React Server Components with the Next.js App Router is where we landed.
The old model: download the JS, then see the content
Before the App Router, the storefront was pure client-side rendering. Opening the tour listing meant downloading a fairly large JavaScript bundle, waiting for components to mount, and only then fetching data and rendering.
Three consequences, ranked by how much they hurt:
- Slow LCP (Largest Contentful Paint): the browser downloads the JS, executes it, calls the API, waits for the response, and only then can it paint anything. The user sits through several sequential steps.
- Poor SEO: search engines crawl what is nearly an empty page. Tour content only appears after the JavaScript has run, and this site lives on organic traffic.
- A bloated JavaScript bundle: fetching logic, data formatting, even code that exists only to render static content, all packaged and shipped to the client. Users never touch any of it.
Splitting roles: Server Components by default, Client only when forced
The rule I follow: everything is a Server Component by default. Only “jump” to a Client Component when you truly need interaction or browser-side state: useState, useEffect, event handlers, or browser-only APIs.
On the tour listing page, most of the UI can render on the server: tour cards, images, descriptions, prices. They only read data fetched from the database and have no interactive logic:
// app/tours/page.tsx — Server Component (the default, no "use client" needed)
import { getTours } from "@/lib/tours";
import { TourFilterBar } from "@/components/tour-filter-bar";
import { TourCard } from "@/components/tour-card";
export default async function ToursPage({
searchParams,
}: {
searchParams: { destination?: string };
}) {
// Fetch directly on the server, so this fetch logic never ships to the client
const tours = await getTours({ destination: searchParams.destination });
return (
<div>
{/* Client Component — needs state for the input and dropdown */}
<TourFilterBar />
<div className="grid grid-cols-3 gap-4">
{tours.map((tour) => (
<TourCard key={tour.id} tour={tour} />
))}
</div>
</div>
);
}
TourCard only displays static data. It stays a Server Component, so not one line of its JavaScript goes to the browser, which just receives pre-rendered HTML. TourFilterBar, on the other hand, has to hold the state of the search box and the destination dropdown, so it has to be a Client Component:
// components/tour-filter-bar.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function TourFilterBar() {
const [destination, setDestination] = useState("");
const router = useRouter();
function handleSearch() {
router.push(`/tours?destination=${encodeURIComponent(destination)}`);
}
return (
<div className="flex gap-2">
<input
value={destination}
onChange={(e) => setDestination(e.target.value)}
placeholder="Search destinations..."
/>
<button onClick={handleSearch}>Search</button>
</div>
);
}
The "use client" boundary wraps exactly the interactive parts: the booking form, the cart, the filters. Content display stays on the server, and no extra JS goes down.
Laid out along the request flow, the boundary sits here.
flowchart TB
subgraph LIST["Tour listing page"]
REQ["Request /tours"] --> PAGE["ToursPage — Server Component"]
PAGE --> FETCH["getTours() reads the database on the server"]
FETCH --> CARD["TourCard — Server Component, no JS sent to the client"]
PAGE --> BAR["TourFilterBar — Client Component, useState for input and dropdown"]
BAR --> ROUTER["useRouter changes the destination query on the URL"]
end
subgraph DETAIL["Tour detail page"]
SLUG["Request /tours/[slug]"] --> DETAILPAGE["TourDetailPage — Server Component"]
DETAILPAGE --> FAST["Tour name and description render immediately"]
DETAILPAGE --> SUSP["Suspense wraps LivePriceBlock"]
SUSP --> SKEL["PriceSkeleton shows first"]
SUSP --> PRICE["LivePriceBlock calls the exchange rate service"]
PRICE --> FILL["Real price fills in where the skeleton was"]
end
Streaming the slow part with Suspense
The tour detail page pulls together several data sources of different speeds. The basic tour info is fast and can be cached for a long time. The final price has to be converted at the exchange rate at the moment of viewing, which is slower because it calls an external rate service in near real time.
Waiting for all the data before rendering the whole page means waiting on the slowest part. The App Router can stream UI piece by piece with Suspense: whatever is ready goes out first, and the rest appears as soon as its data arrives:
// app/tours/[slug]/page.tsx
import { Suspense } from "react";
import { getTourDetail } from "@/lib/tours";
import { LivePriceBlock } from "@/components/live-price-block";
import { PriceSkeleton } from "@/components/price-skeleton";
export default async function TourDetailPage({ params }: { params: { slug: string } }) {
// Fast part: render immediately, no waiting for the exchange rate
const tour = await getTourDetail(params.slug);
return (
<article>
<h1>{tour.name}</h1>
<p>{tour.description}</p>
{/* Slow part: streams separately, doesn't block the rest of the page */}
<Suspense fallback={<PriceSkeleton />}>
<LivePriceBlock tourId={tour.id} basePrice={tour.basePrice} />
</Suspense>
</article>
);
}
LivePriceBlock is its own async Server Component and calls the exchange rate service itself. Because it’s wrapped in Suspense, the tour title and description reach the browser and show up right away. The price area shows a skeleton, then fills in when the rate comes back. The page doesn’t freeze waiting on the slowest piece.
Results: a smaller bundle, a faster LCP
Applied to the storefront, a few metrics moved clearly:
- Much less JavaScript sent to the client: most of the content-rendering logic no longer has to be bundled for the browser.
- Faster LCP (Largest Contentful Paint): the main content is rendered on the server, not waiting for JS to download and run before it fetches data.
- Smoother real-time pricing: users see the main content right away, instead of a blank screen while everything loads at once.
A few easy mistakes
- Don’t put “use client” too high in the component tree: if a parent has “use client”, the whole static subtree below it also runs on the client, and you lose every benefit of Server Components. Push the boundary as low as you can and wrap only the part that needs interaction.
- Sensitive data in Server Components: since the code runs on the server, it’s very easy to query the database directly or use a secret API key right inside a component. Watch out for serializing that data and sending it to the client through props.
- Make the Suspense fallback decent: a sloppy loading skeleton causes layout shift when the real content loads, which drags your Core Web Vitals CLS score the opposite way from the LCP gains you just made.
Server Components aren’t a switch you flip to get faster. The work is deciding clearly what needs to run where. For an e-commerce page that needs both speed and SEO, it’s worth it.
Keep reading
Need a developer for a contract or freelance project?
I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.