I Killed the Loading Spinner — Without Next.js, Without SSR, Without Any Framework

Every time your React app shows a loading spinner on first render, it's confessing something.
"I have no idea what data to display. Give me a second while I figure it out."
Users don't care about your architecture decisions. They don't know you're running a single-page application that needs to make API calls before it can render content. They see a blank screen, then a spinner, then content. In 2026, that feels slow — even if the total time is under a second.
On mentoring.oakoliver.com, I eliminated the loading spinner entirely for public pages. When a user hits the mentors page, they see mentor cards immediately. No flash of empty content. No skeleton loader. No React Query loading state. The data is already there before React even mounts.
I did this without Next.js. Without Remix. Without any framework's server-side rendering.
Just Bun, Elysia.js, and a pattern so simple it almost feels like cheating.
I – The Waterfall That Steals Your Users
Here's what most React SPAs do when a user visits a page.
The browser requests the URL. The server returns an HTML shell — an empty div with an ID of "root." The browser downloads the JavaScript bundle. React mounts and renders a loading state. A data-fetching hook fires an API call. The server processes the request, queries the database, and returns JSON. React re-renders with the actual data. The user finally sees content.
That's a waterfall of three sequential network requests. HTML, then JavaScript, then API data. On a cold load, you're looking at 500 milliseconds to 2 seconds depending on the connection. The loading spinner is visible for the entire API fetch duration.
Now here's the approach that changed everything for us.
The browser requests the URL. The server queries the database for the page's data. The server injects that data directly into the HTML. The browser downloads the HTML — which now contains the data. The browser downloads the JavaScript bundle. React mounts, finds the data already in React Query's cache. The user sees content immediately on the first render.
No loading state. No spinner. No waterfall. The data travels with the HTML.
By the time React mounts, the data is already in memory.
II – The HTML Comment That Changes Everything
At build time, our compiler generates HTML templates for every route and locale combination. Each template contains a single HTML comment that serves as a placeholder.
That comment is invisible. Crawlers ignore it. Browsers ignore it. It has zero rendering cost. It exists solely as a string replacement target.
When a real request arrives, the Elysia server does two things. It loads the data for that route from the database. Then it replaces the comment with a script tag that sets the data on a global window property.
One database query. One string replacement. That's it. No React running on the server. No streaming SSR. No hydration boundaries. No framework magic.
The resulting HTML now contains a script tag with the serialized data sitting right next to the application's script tag. When the browser parses the HTML, it executes that data script first. By the time the application bundle loads and React mounts, the data is already on the window object, waiting to be consumed.
III – Pre-Populating React Query Before First Render
This is where the pattern becomes powerful.
The entry point of our React application reads the global data property and calls React Query's cache population method before calling render. Not after mount. Not in an effect. Before the first render even happens.
When the mentors page component mounts and calls its data-fetching hook, React Query checks its cache first. The data is already there. The hook returns immediately with the cached data, the loading flag set to false.
The component has never rendered with a loading state. The first render is the data render.
This means no flash of empty content. No skeleton loading animation. No layout shift when data arrives. The page renders fully formed on the first paint.
The pre-population is selective. If the server injected mentor data, the cache is populated with the mentors query key. If it's a mentor detail page, the cache is populated with that specific mentor's key. If the data injection failed for any reason — say the database was down — no cache population happens, and React Query falls back to its normal fetch-on-mount behavior.
The pattern is an optimization, not a requirement. If it fails, the app degrades gracefully to standard client-side data fetching. The user gets a loading spinner, but only when something is actually wrong.
IV – Why Not Server-Side Rendering?
A fair question. Next.js, Remix, and other frameworks solve this exact problem with SSR. Why avoid it?
First, we don't need server-rendered HTML. Our public pages aren't interactive on first render. The user sees mentor cards, reads bios, clicks "Book a Call." The HTML shell with a loading spinner is sufficient structure. The visual content comes from data, not from server-rendered React trees.
Second, SSR adds latency to every request. Server-side rendering means React runs on every page load. Even with streaming, there's CPU overhead. Our approach runs a database query and a string replacement — no React execution on the server at all.
Third, SSR introduces hydration mismatches. The bane of every SSR developer's existence. Server-rendered HTML must match client-rendered HTML byte for byte, or React throws warnings and potentially re-renders from scratch. Our approach avoids this entirely because React never runs on the server.
Fourth, we already have our own build pipeline. Bringing in Next.js would mean abandoning our Bun.build() setup, learning a new build pipeline, and losing our custom code-splitting strategy — three separate bundles for public, auth, and private routes. Our stack is simpler.
Fifth, the entire pattern is roughly 185 lines of code. About 50 in the compiler for template generation and data injection. About 120 in the data loaders for route-specific database queries. About 15 in the entry point for cache pre-population. That's it. Adding a framework for this felt like bringing a cruise ship to cross a river.
V – The Data Loaders: Route-Aware Database Queries
Each route has a corresponding data loader that knows exactly what to fetch.
The homepage loader fetches featured mentors. The mentors listing loader fetches approved and featured mentor profiles with their ratings and review counts. The mentor detail loader fetches a specific mentor's full profile, recent reviews, and upcoming availability slots.
Every loader is a Prisma query. Direct database access, no intermediate API layer. This means the data injection path has one fewer network hop than the client-side fetch path. The server doesn't call its own API — it goes straight to the database.
One subtle detail we learned the hard way: Prisma returns Decimal objects for decimal database fields. These don't serialize cleanly to JSON — you get deeply nested objects instead of plain numbers. Every data loader converts Decimal values to plain JavaScript numbers before injection. A small thing, but without it, the client-side components receive data in the wrong shape and silently break.
The dynamic route loader for mentor detail pages is particularly interesting. It extracts the mentor ID from the URL path, fetches the specific mentor's data including reviews and availability, and the entry point uses that mentor's ID as part of the React Query cache key. When the detail page component mounts and calls its hook with the same key, the data is already cached.
VI – Cache Behavior: The 5-Minute Sweet Spot
React Query's cache is configured with a 5-minute stale time.
First render: data comes from the pre-populated cache. Instant. No network request.
For the next 5 minutes: navigation between pages uses the cached data. Visiting the mentors page, clicking into a mentor detail, navigating back — no refetch. The cache serves everything.
After 5 minutes: the data is "stale." React Query still serves the cached data immediately — no spinner — but triggers a background refetch. If the refetch returns new data, the UI updates silently.
This gives you the best of both worlds. Instant first render from server-injected data, and eventual freshness from background refetch.
The user never waits. The data is never more than 5 minutes old.
VII – 480 Templates, 200 Milliseconds
The HTML templates are generated at build time for every route and locale combination. That's 12 public routes across 40 locales — 480 cached templates.
All 480 are generated in roughly 200 milliseconds. The generation is fast because it's pure string concatenation. No React rendering. No DOM manipulation. Just template literals and string replacement.
Each template is cached in a lookup table for O(1) retrieval at request time. A request for the Portuguese mentors page gets the Portuguese template — with Portuguese SEO tags and meta descriptions — populated with the same mentor data from the database. The data is language-agnostic. The template provides the localized shell.
Total memory footprint for all 480 templates: about 1.8MB. Our Bun process uses roughly 80MB total. The templates are less than 2.5% of memory usage. The pattern scales linearly, and the constant factor is small.
Want to see this pattern in production? The mentoring platform at mentoring.oakoliver.com serves every public page with pre-injected data and zero loading spinners. And if you're building AI-powered micro-apps, vibe.oakoliver.com uses the same data injection approach for its marketplace pages.
VIII – Graceful Degradation: The Safety Net
What if the database is down when a page is requested?
The data loader catches the error and returns an empty object. The template injection places an empty data object on the window. The entry point finds nothing to pre-populate. React Query's data-fetching hook fires a normal API fetch on mount.
The user gets a loading spinner — but only when the database is actually down. In the normal case, they never see it.
This means the SSG hydration pattern is a performance optimization, not a load-bearing wall. Remove it and the app still works. Every component still fetches its data via React Query. The pre-population just makes the first render faster.
No broken pages. No error screens. No conditional logic that checks "did the server inject data?" The absence of data is handled by the same loading state that would exist in a vanilla React app.
This is what I mean by "additive optimization." It makes things better when present and changes nothing when absent.
IX – The Performance Numbers
We measured Time to First Contentful Paint and Largest Contentful Paint on the mentors page.
Without the SSG hydration pattern, First Contentful Paint was around 850 milliseconds. With it, 420 milliseconds. Largest Contentful Paint — the mentor cards themselves — dropped from 1,400 milliseconds to 480. Time to Interactive went from 1,600 to 550.
LCP dropped by 66%. The mentor cards render on the first frame instead of waiting for an API response.
Data fetch requests went from 2 (HTML plus API) to 1 (HTML with data embedded). Loading spinner visibility went from about 600 milliseconds to zero.
The user perceives the page as instant. And for all practical purposes, it is.
X – When NOT to Use This Pattern
This approach works beautifully for public pages with SEO requirements, data that doesn't change between page loads, and pages where loading speed is critical for user experience.
It's not ideal for authenticated pages where data is user-specific. Each request would need a unique database query scoped to the logged-in user. It works, but the caching benefit diminishes when every response is unique.
It's not great for highly dynamic data that changes every second. The 5-minute stale time would show outdated data.
And it's counterproductive for pages with large data payloads. Injecting 500KB of JSON into the HTML defeats the purpose of faster loading.
For our authenticated dashboard pages — the user's dashboard, messages, and sessions — we use standard React Query fetching. The loading spinner is acceptable there because the user is already committed. They logged in. They expect a brief load.
The pattern shines brightest on public-facing, content-heavy, SEO-critical pages. Which is exactly where first impressions are made and bounce rates are determined.
XI – The Simplicity Argument
The entire SSG hydration system fits in 185 lines of application code.
Compare this to setting up Next.js with server-side props, ISR configuration, API routes, hydration boundaries, and streaming SSR. Or Remix with loaders, actions, defer/await, and error boundaries.
Those frameworks solve a much broader problem set. If all you need is "put data in the HTML before React mounts," you don't need a framework.
The best code is code that does one thing well. Our SSG hydration does exactly one thing: eliminate the loading spinner. It does it in 185 lines. It has no dependencies beyond what we already use. And it degrades gracefully to standard behavior if anything goes wrong.
The loading spinner is not an inevitability of single-page applications. It's a choice. And usually, it's the lazy choice.
If your server knows what data the page needs, and your server is already generating HTML — inject the data. One HTML comment, one string replacement, a few cache pre-population calls. Your users get instant content. Your Lighthouse scores improve. Your bounce rate drops.
You don't need Next.js for this. You don't need SSR. You just need a placeholder and the willingness to put data where it belongs: in the HTML.
What's the laziest optimization you've ever shipped that had an outsized impact on user experience?
– Antonio