about this site
or: how I spent a month building a custom routing system. for a portfolio.
I wanted two things from this site:
- SEO. A prospective employer typing my name into Google should land here, which means SSR, so the page arrives pre-rendered instead of blank while a JavaScript bundle downloads.
- SPA reactivity. Click a link, something happens now. No dead feedback.
Next.js is supposed to give you both. Spoiler: it doesn't quite, and I spent a month figuring out why.
it started with a Reddit comment
I was reading about loading.tsx, Next's answer to "show a skeleton while the next page loads." Someone in the comments mentioned it didn't really work on 3G. I threw devtools into Slow 3G on my own app, clicked a link, and watched nothing happen for three seconds. Then everything appeared at once.
That's when the rabbit hole opened.
it's not just me
I went to Vercel's own e-commerce demo, demo.vercel.store, and reproduced it on their own site. Sometimes the skeleton showed, sometimes it didn't. It took me a while to figure out why the behaviour was inconsistent: prefetch.
When a link is in the viewport, Next prefetches the route, which includes the skeleton chunk, so on navigation the skeleton is already on the client and shows immediately. Disable prefetch (or click something off-screen, or hit the site cold) and the issue comes back, even on their demo. Try it yourself: headwear page, filter click, Slow 3G, no prefetch.
It's a real framework-level issue, not something I was doing wrong.
learning the plumbing
Here's the short version of what I learned. Suspense is a boundary that shows a fallback while something inside it is still waiting. loading.tsx is just Next wrapping your route in one of those boundaries automatically.
The catch: on a normal navigation, the thing it's waiting on is the server response itself. The fallback is part of that response. So Suspense can only start showing the skeleton once the server has already answered. On a fast connection that's milliseconds and you never notice. On Slow 3G the server takes three seconds to reply, the skeleton arrives with it, and by then there's nothing left to wait for. The user clicks, stares at the old page for three seconds, and the new page just appears.
What I actually wanted was a "something is happening" signal the moment you click, living in the client bundle so it doesn't have to wait for the server at all.
the observer-pattern detour (a.k.a. next-sail)
My first instinct was, of course, to over-engineer it. I built a little library I called next-sail: a global pub/sub system with a Captain (links that announce when they're about to navigate), a Ship (the state store), and Sailor components (wrappers that subscribe to specific routes and swap themselves for a fallback when that route is pending). Classic observer pattern. Clean-looking API.
It almost worked. I got the broadcast working, I got the Sailor subscribing, I could see the events firing in the console. But the closer I got to done, the more small problems piled up: hydration mismatches between the server and client, conditional-render traps that remounted the real page on every navigation, fighting Suspense to do things it isn't designed for, figuring out when navigation was actually done so the skeleton could go away, and just too many moving parts for a three-page site. Eventually I shelved it.
the simpler path
Back to researching. Poking through the React docs, I came across useTransition, which on paper described exactly the shape of the problem I was trying to solve by hand: a hook that lets you mark an update as non-urgent and gives you back an isPending flag that's true while the update is working and flips back to false when it's done. A perfect "something is happening" signal built straight into React, no observer needed.
Around the same time I found the Reddit thread, which added one missing piece: if you call window.history.pushState before router.push, the URL updates synchronously. That matters for two reasons. The address bar is correct the instant you click, and the navbar underline (which reads the same pending URL) slides into place right away too, even before the new page has started loading.
The whole thing collapsed into a single provider:
const [pendingUrl, setPendingUrl] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
useEffect(() => {
if (!isPending) setPendingUrl(null);
}, [isPending]);
function navigate(href: string) {
setPendingUrl(href); // urgent, skeleton shows now
startTransition(() => {
window.history.pushState(null, "", href); // URL updates instantly
router.push(href); // the RSC fetch
});
}why this works
React splits updates into two lanes. Updates outside startTransition are urgent: React commits them on the next paint, no matter what. Updates inside a transition are non-urgent: if anything they trigger suspends, React holds the whole transition and keeps showing the old UI until the suspending work resolves, rather than flashing a fallback.
So the sequence is:
setPendingUrl(href)fires urgently, so the skeleton paints immediately.pushStateupdates the address bar synchronously, so the URL is already correct.router.pushsuspends while it fetches the new RSC payload. React flipsisPendingto true and waits.- The payload arrives, the transition commits, React flips
isPendingback to false. - The
useEffectsees!isPendingand clearspendingUrl. The skeleton disappears in the same paint as the real content.
That's the whole trick. isPending is already a perfect "navigation complete" signal, I just had to read it from a useEffect instead of inventing my own. No pub/sub, no completion detection, no edge cases. React was always going to tell me.
the part that aged badly
Writing this page, I went back to the next-sail repo to pull up screenshots of the library I'd shelved. The demo didn't work. The skeleton never showed.
The actual cause, a year later, turned out to be a single prop mistake. Nothing framework-level, just a bad type. But at the time, the debugger output and console messages were wildly inconsistent across reloads, sometimes nothing, sometimes errors pointing at the wrong place, and after already spending hours wading through the hydration, conditional-render, and completion-detection issues from the previous section, I burned another ten minutes staring at this one prop without spotting it. That was the last straw. I closed the tab and started over.
so which version is better?
Honestly? They're solving slightly different problems.
next-sail is library-shaped. Generic Captain/Sailor API, subscribe to any route list, drop fallbacks anywhere in the tree. If I were shipping this as an npm package for other people's projects, the observer pattern genuinely is the right shape. It gives you per-Sailor fallbacks, composable subscriptions, and logic that doesn't assume your whole app is a single provider.
This portfolio's version is right-sized. One provider, one pendingUrl string, one useEffect, a registry that picks a skeleton by pathname. Thirty-something lines total. For a three-route site, library architecture would just be ceremony.
The lesson that actually stuck: React's transition model already does the hard part. Most of what I was trying to build by hand, knowing when navigation started, when it finished, how to hold state during the in-between, is useTransition's entire job. I just needed to stop inventing around it.
Now that I understand the pieces properly, I want to go back to next-sail with everything I learned from this provider: rewrite it around useTransition, fix the prop types that bit me, clean up the observer API, and actually ship it on npm. The library version of this idea is still the one I wish existed.
the punchline
All of this, a half-abandoned library, thirty lines of provider code, a month of dead ends, reading RSC internals, rearranging folder trees, giving up and coming back, and discovering a year later that I gave up over one bad prop type, is for a portfolio.
A website with three routes. One of which is this page.
The funny part is I'd do it again. I learned more about React's transition model, Next.js's routing internals, and the genuine limits of SSR from this one stubborn problem than from months of shipping normal features. And if you're reading this, probably as someone thinking of hiring me, you now know exactly what happens when I get curious about something. Including, apparently, the part where I don't read my own prop types.
further reading
- Reddit: App Router feels fundamentally broken on slow networks
- vercel/next.js#43548, closed without a fix
- vercel/next.js#54667, auto-closed, still reproduces
- Next.js 14.1: window.history.pushState as the documented instant-URL workaround
- React: useTransition