Rebuilding FragHub in Next.js: What React Server Components Actually Buy You
I built fraghub.gg as a PUBG stats and replay tool: search a player across Steam, Xbox, or PlayStation, pull their stats, and scrub through a 2D replay of any match. The first version was Laravel serving a React frontend. It worked, but the stat pages always felt like they had to wake up before they showed you anything, so I rebuilt it on Next.js with the App Router. Here is what I actually noticed after moving.
The stats were painted in the browser
The stats pages are the whole point of the site: one per player, one per match, thousands of them. In the first version the server sent a mostly empty page, React booted in the browser, fetched the stats from an API, then drew them. On a fast connection you barely noticed, but the shape of it bothered me: the numbers already existed on the server, next to the database, and yet every visit round-tripped back out to an API to get them before anything rendered. I wanted the server to just render the page with the stats already in it.
Server Components render the page on the server
That is exactly what the App Router does. Every component is a Server Component by default: it runs on the server, can be async, and talks to the database directly, then ships HTML.
// simplified. the real route is /pubg/[platform]/[player]
export default async function PlayerPage({ params }) {
const { player } = await params;
const stats = await getPlayerStats(player); // runs on the server, no client fetch
return <StatBlock stats={stats} />;
}
Now when anyone requests that URL, the server runs the query and sends a complete page with the K/D already in the markup. No shell, no round-trip. Everything else I noticed followed from that.
What I actually noticed
It felt faster. The old page had to download a shell, start React, then make an API round-trip before a single number showed up. The server component skips all of that: it is already next to the database, so it queries and renders in one pass and the browser paints real content on the first response. With the pages cached, the stats are just there.
I was shipping less JavaScript. In the old version the entire React app, all the table logic included, downloaded and ran in every browser. Now the tables are server-rendered and never ship as client code. The one genuinely interactive part of the site is the replay viewer, a canvas that scrubs match telemetry, so that is the only big client chunk, and it only loads on the match page that needs it.
"use client"; // the replay viewer is the exception, not the rule
The server fetches the telemetry and passes it down; the canvas hydrates on top. Server does the data, client does the interaction.
Caching does most of the work. The player and match pages are programmatic, generated from data, so I do not want to rebuild them all on deploy or re-hit the rate-limited PUBG API on every visit. Incremental Static Regeneration handles that: a page renders on first request, gets cached, and quietly refreshes in the background on an interval. One line:
export const revalidate = 60;
What it cost
Two things genuinely tripped me up. The server/client split is a real mental shift: which components run where, and what is allowed to cross the boundary, is a new thing to hold in your head. You cannot use useState in a Server Component or pass a function into a Client Component, and I hit those walls a few times before the model stuck.
And the caching that makes it fast will also confuse you. The App Router caches at several layers, and more than once a change did not show up because something upstream was still cached. You have to learn revalidate and cache tags before it stops surprising you.
Was it worth it
For this site, yes, and specifically because it is data-heavy. Every player and match page now renders on the server with the content already in it, the pages feel instant instead of waking up, and they ship less JavaScript on the way. If I were building a plain marketing site I would not reach for any of this. The payoff was concrete because the problem was: pages that finally render the data in one pass instead of fetching it back after the fact.