I've come to really dislike SPAs. They make sense from the developer side: yeet everything into React and then have a lovely time assembling things out of components, then write a nice clean API backend. They also hand you about a megabyte of javascript to achieve this.
And I hate those API backends. There's two kinds of API: an API to share with other people on the internet, and an API for yourself. You'll know if you have the first kind because you'll be giving people API keys. If you have the second kind then you're busy inventing a big ol' taxonomy to carefully carry SQL queries over the wire. Or wrangling GraphQL. If you're really keen on """REST""" then you might even make an endpoint for every object in your system. It's tempting. And it's the cardinal sin. You've just given up every advantage of a relational database and now you're building a relational database query in Javascript and you'e sending 20 different requests across the wire to put all of your data together. I've personally written code that does 5 or 6 round trips to assemble a page: initial fetch, then all the javascript, then a fetch to get the username, then a fetch to get user-associated objects, then another to get the JSON for the business objects they interacted with, and then the images for that. If you're on the wrong side of the atlantic you've just stacked up a full 1200ms worth of round trips. And this was in next.js, a tool designed to let you do "server-side rendering"!
So let's back it up a bit. For stuff that has nothing to do with whether a user is logged in, you can just straight up write an API endpoint that does all the queries and assembles it all into one request. Y'know, the blog post, plus the comments, plus the author bio, plus relational links, all in one nice JSON-API or ad-hoc JSON object, ready to inject into your React app.
But let's back up again: if you've got the user's credentials when they make the request (and if you use session cookies, you do) then your servers can do intra-cloud requests to your data API. Now you can go from user objects back to business objects. Now you've cut things down: there's the initial request, then all the javscript, then all the user's data and business objects in one nice json bundle, then images. That's fully three fewer requests, now your transatlantic user is only waiting 800ms on the initial load, and less if their cache is warm. Neat!
I think you can also set your webserver to pre-emptively ship over the stuff it knows you're going to request, so maybe we save another round-trip and we're at 600ms.
But why not back up again? You're still working around the original sin: you're not building the HTML in the same place that you're running relational database queries, so you're spending a bunch of engineering effort making and calling endpoints just so you can render your HTML on the client. You could start shipping the user all the HTML they need pre-rendered.
Here's where Custom Elements are a really neat technology!
At first I tried to use Custom Elements like React. This makes them kind of overloaded: suddenly you're in a world of pain building plain old DOM objects with plain old Javascript. But that's not how to go with the grain of HTML5. Custom Elements are about progressive enhancement. What you want, hopefully, is a fully-formed piece of HTML that will work with Javascript turned off. When you add bits and bobs with Javascript, Custom Elements let you bundle up your code nice and straightforwardly: there's connect/disconnect hooks (basically mount/unmount) so you can set up your listeners properly, and all the associated code goes in the same class. You can just store data on the HTMLElement object or in the local stack. Event listeners let you decide when to update, but it's best to keep things nice and simple.
I know it's not quite as magical as React Hooks, which do have some distinct advantages with respect to making sure your listeners get unregistered at the right moment and doing a minimum of DOM changes. But I don't think it always needs to be that magical, either. With server side rendering you do have to render an entire new document, sure. But how often is that document showing hundreds or thousands of elements, enough elements that React's careful work minimization starts to shine?
I've just written a hundred lines of code to play with these. I've got a button that loads data and fires an event with a subject when it's got it. It uses AbortControllers to coordinate cancelling fetches where appropriate. Another component listens for those events and places the data inside a sub-element. It's all very minimal, and it feels a little more raw with plain Javascript, but the whole site fits in 14kb of hand-written code and runs on a static webserver.
We used to use frameworks such as ASP.net (ew) and Seaside (yum) which simply stored state in the query string. You took an action, it'd re-render on the server side and you'd get the next state of your app. It's kind of gross with state, but at the same time it has distinct advantages. You write a Seaside app like an integrated application and it abstracted away the network. You simply wrote code that updated local state and the fact it was using the browser as a rendering engine was neither here nor there.
I know it's not a good fit for every use case but I think there's a lot to be said for that line of thought in web development. Nowadays I understand Seaside makes use of Javascript to update subcomponents without having to render an entire page. I think they have the right idea. Right now it's a lot easier to just boot up another next.js project, but I like to think that maybe there's room to once again build pages on the server and then enhance them with a minimum of javascript. I just wish I had a better idea of which frameworks go with that particular grain too.