Introducing: Thunderous CSR (Client Side Rendering) This package is experimental. It may not be suitable for production use at this time, as it is subject to bugs and breaking changes. Thunderous CSR (Client-Side Rendering) brings SPA-like navigation to traditional multi-page apps (MPAs). It intercepts navigation and updates only select parts of the page, enabling smoother transitions and better performance. The best part? Your pages are still just HTML. No router, no framework, no server needed. What Problem Does This Solve? Traditional MPAs (Multi-Page Applications) have a major UX drawback: every navigation triggers a full page reload, causing:
  • Flash of white screen
  • Re-execution of all JavaScript
  • Broken transient UI state (open dropdowns, playing videos, etc.)
SPAs (Single-Page Applications) solve this, but introduce complexity:
  • Client-side routing libraries
  • State management headaches
  • Large JavaScript bundles
  • SEO challenges
Thunderous CSR gives you the best of both worlds: the simplicity of MPAs with the smooth UX of SPAs. Quick Start npm install thunderous-csr import { View } from 'thunderous-csr'; View.define('t-view'); // You can use any tag name <!-- On every page that should have SPA-like navigation --> <t-view id="main"> <!-- Your page content --> </t-view> That's it. Navigation will be captured by the <t-view> element, which looks for itself on the incoming page and swaps its content. Thunderous CSR only handles the client side. It expects HTML to be served separately. Thunderous Server makes it easy, but any server works, as long as the responses are valid HTML. How It Works The Core Concept: View Elements with IDs Thunderous CSR uses the Navigation API—a modern web platform feature—to intercept navigations. When you click a link:
  1. The navigation is intercepted
  2. The destination page is fetched in the background
  3. The <t-view> element with a matching ID swaps its content
  4. The URL updates using the History API
<!-- page1.html --> <body> <t-view id="content"> <h1>Page 1</h1> <a href="/page2">Go to Page 2</a> </t-view> </body> <!-- page2.html --> <body> <t-view id="content"> <h1>Page 2</h1> <a href="/page1">Back to Page 1</a> </t-view> </body> When navigating from Page 1 to Page 2, the <t-view id="content"> on Page 1 gets its inner content replaced with the content from the matching view on Page 2. Why the ID Matters The id attribute must be identical across pages for content swapping to work. This allows you to have multiple independent views on the same page: <body> <t-view id="sidebar"> <!-- Sidebar content can differ per-page --> <nav>...</nav> </t-view> <main> <t-view id="main"> <!-- Main content swaps independently --> </t-view> </main> <script type="module" src="/main.js"></script> </body> API Reference View.define(tagName) Registers the View custom element with your preferred tag name. Call this exactly once: import { View } from 'thunderous-csr'; // Register with your preferred tag name View.define('t-view'); // Standard View.define('app-view'); // Custom name View.define('page-content'); // Your choice // Call this EXACTLY ONCE in your application viewRegistry If you need to dynamically look up the registered tag name: import { viewRegistry } from 'thunderous-csr'; // Look up the registered tag name dynamically const tagName = viewRegistry.getTagName(View); // Returns 't-view' See Thunderous Registries for more information. ViewElement Interface Each <t-view> element exposes these read-only properties: Property Type Description status 'pending' | 'ready' | 'error' Current navigation state finished Promise<void> Resolves when navigation completes const view = document.getElementById('main'); console.log(view.status); // 'pending', 'ready', or 'error' // Wait for navigation to finish await view.finished; console.log('Navigation complete!'); Features View States & CSS Classes The View element automatically toggles CSS classes based on its state: State Class When Active Loading .pending During navigation, before content swaps Complete .ready After successful content swap Error .error If navigation fails t-view.pending { opacity: 0.7; } t-view.ready { opacity: 1; transition: opacity 0.3s ease; } t-view.error { border: 2px solid red; } Loading Overlay By default, a semi-transparent overlay appears during navigation. Customize it with the loading-overlay slot: <t-view id="main"> <div slot="loading-overlay" class="my-spinner"> <img src="/spinner.svg" alt="Loading..."> </div> <!-- Main content here --> </t-view> Native Support Since this uses the built-in Navigation API, you may also take advantage of its corresponding CSS features:
  • @view-transition
  • view-transition-name
  • view-transition-class
  • :active-view-transition
  • ::view-transition
  • ::view-transition-group
  • ::view-transition-image-pair
  • ::view-transition-old
  • ::view-transition-new
There are also several related events that you can listen to:
  • navigate
  • navigateerror
  • navigatesuccess
  • currententrychange
  • pageswap
  • pagereveal
  • pageshow
And of course it's worth reviewing the Navigation Interface itself. That is, the methods and properties available on the global navigation object:
  • activation
  • canGoBack
  • canGoForward
  • currentEntry
  • entries()
  • back()
  • forward()
  • navigate()
  • traverseTo()
  • updateCurrentEntry()
  • NavigationEvent
    • destination
    • canIntercept
    • downloadRequest
    • navigationType
    • userInitiated
    • info
    • signal
    • formData
    • sourceElement
    • hasUAVisualTransition
    • intercept()
    • scroll()
Browser support for these features may be limited. Please check the support tables on the linked MDN pages before using them in production. Common Anti-Patterns ❌ Don't: Define Different IDs on Different Pages <!-- page1.html --> <t-view id="content">...</t-view> <!-- page2.html --> <t-view id="main">...</t-view> <!-- Different ID! --> ✅ Do: Use identical IDs across pages: ❌ Don't: Nest Views in Other Views <!-- BAD: Confusing behavior, will cause errors --> <main> <t-view id="main"> <p>Main content</p> <aside> <t-view id="aside"> <p>Side content</p> </t-view> </aside> </t-view> </main> ✅ Do: Define all views in parallel scopes. <main> <t-view id="main"> <p>Main content</p> </t-view> <aside> <t-view id="aside"> <p>Side content</p> </t-view> </aside> </main> FAQ Q: Does this work without JavaScript? A: Yes, sort of! Thunderous CSR is built with progressive enhancement in mind, so without JavaScript, users just get the standard MPA experience (full page loads). There will be no partial updates, but disabling JavaScript will not break the page. Q: What browsers are supported? A: The Navigation API is considered baseline as of January 2026, so it's widely supported. Not everyone supports the additional features surrounding navigations, but the core functionality employed by Thunderous CSR is expected to work consistently across all major browsers. Q: How does this differ from HTMX or Turbo? A: HTMX uses attributes to define endpoints that fetch partial HTML, while Thunderous CSR selectively swaps content from whole-page responses. Turbo uses a very similar approach, but it's more opinionated and requires global scripts. Thunderous CSR is a single web component you explicitly import and register yourself. Out of these three, only Thunderous CSR operates on the baseline Navigation API, which also plugs into a whole cluster of emerging browser features surrounding it. While not all of them are fully supported yet, it's a solid foundation for future-facing apps. Q: Can I use this with React/Vue/Svelte? A: Technically yes, but Thunderous CSR's partial update model overlaps with the way those frameworks manage DOM updates. In most cases, if you're building the page around one of those frameworks, it's better to use that framework's own routing system instead. One case where they can still work well together is a microfrontend architecture, where Thunderous CSR handles top-level navigation and React, Vue, or Svelte are mounted onto specific elements within each view. Q: Does this intercept form submissions? A: Yes. Thunderous CSR intercepts all navigations, including form submissions. It shouldn't change the handling of the form submission itself, but it does intercept the navigation that results from it. Q: What if the destination page has different CSS/JS? A: Thunderous CSR always swaps the head content, in addition to view content. Each view adds its own handler for navigation, while one global handler manages the overall navigation process. In that global handler, the head content is swapped first, and then it queues a validation to ensure the final rendered content reflects the same HTML you would get by loading the destination page directly. If the validation fails, it swaps the entire root HTML tag. Troubleshooting Issue: "View not found in destination document" warning Cause: The destination page doesn't have a <t-view> with the matching ID. Fix: Ensure all pages that participate in SPA navigation have matching view IDs. The view will be removed from the DOM when navigating to pages without a matching view. NOTE: You may not need to fix this warning if it's expected (e.g., navigating to a page that doesn't have a view). The warning is informational to call attention to a view being removed from the DOM. It's your discretion whether it warrants attention. Issue: Full page reloads happening anyway Cause: Server returns different HTML than expected, triggering fallback behavior. Fix: Check that your server is serving consistent HTML structures. Check the logs for validation errors and verify there are no network errors. Related Packages
  • thunderous — Web component authoring library
  • thunderous-server — Static site generator and dev server (pairs perfectly with CSR)
  • create-thunderous — Project scaffolding CLI (includes CSR by default)