9 min read / , ,

Rebuilding FragHub in Next.js: What React Server Components Actually Buy You

TLDR React Server Components let a data-heavy stats site server-render its content so crawlers and users get HTML fast, fixing the empty-shell SEO problem client rendering created.

I built fraghub.gg as a PUBG stats and match replay tool: search any player across Steam, Xbox, or PlayStation, pull their full stat breakdown, then scrub through a 2D replay of any match. The first version ran on Laravel with a React frontend. It worked. Then I rebuilt the whole thing on Next.js with the App Router and React Server Components.

This is the story of why, what React Server Components (RSC) actually buy you for a data-heavy site, and the SEO lesson that turned out to be the real reason I moved.

The problem: Google saw an empty shell

FragHub has thousands of pages that matter for search: one per player, one per match, plus leaderboards and weapon pages. These are exactly the long-tail pages a stats site lives or dies on. Someone searches a player name, and ideally they land on that player's FragHub page.

In the Laravel version, those pages were rendered client-side. Laravel served a thin HTML shell, React booted in the browser, fetched the stats from an API, and painted the numbers. To a human on a fast connection it looked fine. To Googlebot it often looked like this:

<div id="app"></div>
<script src="/build/app.js"></script>

That is the whole page, as far as the initial crawl is concerned. Google can run JavaScript, but it does so on a second pass, on its own schedule, with no guarantee of when. For a site with thousands of thin-ish stat pages, that second pass is unreliable. The result was player pages that crawled as near-empty documents. The content was real, but the crawler saw a shell.

This is not a FragHub-specific mistake. It is the default failure mode of any client-rendered app with content that needs to be indexed. You ship an interactive experience and quietly hand the crawler a blank page.

What RSC actually is (the short version)

React Server Components are components that run only on the server. They render to HTML (technically to a serialized React tree) on the server and never ship their JavaScript to the browser. In the Next.js App Router, every component is a Server Component by default. You opt into client behaviour explicitly with "use client" at the top of a file.

The mental model that finally made it click for me: a Server Component is allowed to be async and talk directly to your data layer. There is no API round-trip from the browser, because the component is already on the server, next to the database.

// app/players/[id]/page.tsx
// This is a Server Component. It runs on the server, not the browser.
import { getPlayerStats } from "@/lib/pubg";

export default async function PlayerPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const stats = await getPlayerStats(id); // direct data access, no client fetch

  return (
    <main>
      <h1>{stats.name}</h1>
      <dl>
        <dt>K/D</dt>
        <dd>{stats.kd}</dd>
        <dt>Win rate</dt>
        <dd>{stats.winRate}%</dd>
      </dl>
    </main>
  );
}

When Googlebot, or a human, requests that URL, the server runs the query, renders the numbers into HTML, and sends a complete document. There is no empty <div id="app">. The K/D ratio is in the markup. That single change is the thing that fixed the SEO problem.

What RSC buys a data-heavy stats site

Once I had the model straight, the wins stacked up beyond just SEO.

Content in the initial HTML, for free. Every stat page is server-rendered by default. Crawlers, social card scrapers, and screen readers all get real content on the first byte. I did not have to bolt on a separate server-rendering layer or maintain a parallel prerender pipeline. It is the default.

Less JavaScript shipped to the browser. The stat tables, the headers, the layout, none of that needs to be interactive, so none of it ships as client JavaScript. In the Laravel version, the entire React app, including all the table-rendering logic, was downloaded and executed in every visitor's browser. With RSC, only the genuinely interactive parts cross the wire. On FragHub the only thing that truly needs the client is the replay viewer, which renders telemetry on a canvas. So that is the only large chunk of JavaScript that loads, and only on the match page that needs it.

Data fetching lives next to the component that needs it. No more "build an API endpoint, then build a client hook to call it, then wire up loading and error states by hand." The component that displays the player's weapon mastery is the component that fetches it, on the server. Fewer moving parts, fewer endpoints to secure, less client state.

The interactive island stays interactive. This is the part people miss. RSC does not mean giving up interactivity. The replay viewer is a Client Component:

// app/matches/[id]/ReplayViewer.tsx
"use client";

import { useState } from "react";

export function ReplayViewer({ telemetry }: { telemetry: Telemetry }) {
  const [time, setTime] = useState(0);
  // canvas rendering, scrubbing, kill feed, all client-side
  // telemetry was fetched on the server and passed down as a prop
  ...
}

The server fetches the heavy telemetry payload, the page renders instantly with content, and the interactive canvas hydrates on top. Server work and client work each do what they are good at.

Streaming and Suspense: fast content, slow data, no blocking

The PUBG API is not always fast. A player's stat summary comes back quickly, but their full match history with telemetry can be slow. In the old model, a slow data source meant a slow page, because everything blocked on the slowest query.

The App Router lets you stream. You wrap the slow part in <Suspense>, send the fast shell immediately, and stream the slow part in when it resolves.

import { Suspense } from "react";

export default async function PlayerPage({ params }) {
  const { id } = await params;

  return (
    <main>
      {/* fast: renders and ships immediately */}
      <PlayerHeader id={id} />

      {/* slow: streams in when ready, shows a skeleton meanwhile */}
      <Suspense fallback={<MatchListSkeleton />}>
        <RecentMatches id={id} />
      </Suspense>
    </main>
  );
}

The header and core stats hit the browser fast, the match history fills in a moment later. The user sees content immediately instead of staring at a spinner while the slowest query finishes. And critically, the streamed HTML is still real HTML, so it is still crawlable.

ISR for the programmatic pages

FragHub's pages are programmatic: there is one per player and one per match, generated from data, not hand-authored. Rebuilding all of them on every deploy would be absurd, and rendering every one fresh on every request would hammer the PUBG API and my rate limits.

Incremental Static Regeneration is the answer. A page renders on first request, gets cached, and re-renders in the background on an interval I set.

// re-generate this page at most once an hour
export const revalidate = 3600;

A popular player's page is served from cache, fast and cheap, and quietly refreshes in the background so the stats do not go stale. New players get a page on their first visit. I am not pre-building tens of thousands of pages I may never need, and I am not re-querying the API on every hit. For a stats site sitting on top of a rate-limited upstream API, this is close to ideal.

The honest trade-offs

I am not going to pretend this was free.

The mental model is a real shift. "Which components run on the server, which run on the client, and what is allowed to cross the boundary" is a genuinely new thing to hold in your head. You cannot pass a function as a prop from a Server Component to a Client Component. You cannot use useState or browser APIs in a Server Component. The errors are clear once you learn them, but there is a learning curve, and I tripped over the boundary several times early on.

Caching is powerful and surprising. The App Router caches aggressively at multiple layers. That is great for performance and confusing the first time a change does not show up because something upstream was cached. You have to learn revalidate, cache tags, and when data is and is not fresh. Budget time for it.

It is heavier than my static sites. For a genuinely static marketing site, this is overkill, and I would reach for a static generator instead. RSC earns its keep specifically because FragHub is data-heavy and needs server rendering. Match the tool to the shape of the site.

You are tied to a framework's conventions. RSC in practice means the Next.js App Router and its rules. That is a real coupling. I made peace with it because the conventions are good and the SEO payoff was concrete, but it is a coupling worth naming.

The result

The headline win is simple: every player and match page now ships real, server-rendered content in its initial HTML. The crawler sees the stats, not an empty <div>. The pages that a stats site needs indexed are finally indexable by default, not as an afterthought I have to engineer around.

The performance came along for the ride. Less JavaScript shipped, content visible on first byte, slow data streamed instead of blocking. Those are the Core Web Vitals wins, and they are a consequence of the architecture, not a separate optimization pass.

If you are running a data-heavy site that needs its content indexed, and you are client-rendering it, that is the case where React Server Components actually buy you something real. Not hype, not a rewrite for its own sake. A crawler that finally sees your content.