On this page
spa router
introduction
dframework includes a fully integrated client side router called dSPA. it transforms your server rendered application into a single page application without requiring a javascript framework, a build step, or any configuration. every standard <a> tag on your page is automatically intercepted and navigated via fetch, replacing only the targeted portion of the dom while preserving the rest of the page, layout scripts, socket connections, and audio playback.
the router is loaded automatically when your application includes the framework's frontend runtime. there is nothing to install or import.
how it works
when a user clicks a link, the router fetches the destination page as html in the background. it then parses the response into a virtual document, extracts the content inside the configured swap target selectors, and surgically replaces only those regions of the current page. the browser url and history are updated via pushState, the document title is synced, csrf tokens are refreshed, and any scripts in the new content are executed in an isolated scope.
back and forward browser navigation is fully handled. when the user presses the back button, the router fetches the previous url and swaps the content exactly as it would for a forward navigation.
default swap targets
by default, the router looks for the <body> element or an element with id="spa-container" as the region to swap. you override this globally by setting dSPA_DEFAULT_TARGETS in a script tag on your layout before any navigation occurs.
1<script defer>[object Object]>2 dSPA_DEFAULT_TARGETS = ['#spa-container'];[object Object]>3</script>the value is an array of css selectors. the router will try each selector in order and use the first match it finds in both the current page and the fetched page.
links and navigation
anchor interception
every same origin <a> tag on the page is automatically intercepted by the router. when clicked, the router fetches the href and swaps the default target region. no special attributes or classes are needed.
1<a href="/dashboard">dashboard</a>the router skips interception in the following cases:
- the link points to a different origin
- the link has
target="_blank" - the link starts with
#,mailto:,tel:, orjavascript: - the user is holding a modifier key (ctrl, meta, shift, alt)
- the link has the
fullattribute
the d-link element
<d-link> is a custom element that behaves identically to an intercepted <a> tag but provides additional control over targeting and swap behavior through its attributes.
1<d-link href="/dashboard">dashboard</d-link>you can specify which part of the page should be replaced using the target attribute.
1<d-link href="/admin/users" target="#spa-container">users</d-link>if the fetched page uses different selectors for its content regions, you can specify which element to extract from the response using the src attribute.
1<d-link href="/settings" target="#panel" src="#settings-panel">settings</d-link>the mode attribute controls how the new content is placed into the target element.
1<d-link href="/notifications" target="#feed" mode="prepend">load new</d-link>to keep the scroll position after navigation, add the preserve-scroll attribute.
1<d-link href="/feed?page=2" target="#feed" preserve-scroll>next page</d-link>to force external scripts inside the swapped content to reexecute even if they have already been loaded in a previous navigation, add the rerun-scripts attribute.
1<d-link href="/editor" rerun-scripts>open editor</d-link>
opting out of interception
add the full attribute to any <a> or <d-link> element to force a traditional full page navigation.
1<a href="/logout" full>logout</a>
programmatic navigation
you can trigger a spa navigation from javascript using dSPA.navigate(). this performs the same fetch, swap, and history update cycle as clicking a link.
1dSPA.navigate('/dashboard');you can pass an options object to control the target, source, mode, and transitions for the navigation.
1dSPA.navigate('/home', {2 targets: ['#tabContent'],3 transitions: {4 beforeSwapTarget(oldEl, meta) {5 oldEl.style.opacity = '0';6 },7 afterSwapTarget(newEl, meta) {8 newEl.style.opacity = '1';9 }10 }11});the available options are:
targets: array of css selectors for the elements to replace on the current pagesrcTargets: array of css selectors for the elements to extract from the fetched page (defaults totargets)mode: swap mode (inner,outer,append,prepend)preserveScroll: boolean, keeps the scroll position after swaprerunScripts: boolean, reexecutes previously loaded external scriptstransitions: object containing transition hook functionsaddToHistory: boolean, whether to push a new history entry (defaults totrue)
swap modes
the router supports four swap modes that control how new content is placed into the target element.
inner (default): clears the target element's children and inserts the new content's children into it. the target element itself is preserved.
outer: replaces the entire target element with the new content element, including the element itself.
append: appends the new content as the last child of the target element without removing existing children.
prepend: inserts the new content as the first child of the target element without removing existing children.
transition hooks
the router exposes lifecycle hooks that let you animate content transitions during navigation. hooks are async aware. the router will wait for any returned promise to resolve before proceeding.
global transitions
you set global transition hooks by assigning an object to dSPA.transitions in your layout. these hooks will fire on every spa navigation.
1<script defer>[object Object]>2 const drawer = select('d-drawer');[object Object]>3[object Object]>4 dSPA.transitions = {[object Object]>5 beforeSwap(oldNode, meta) {[object Object]>6 return drawer.close();[object Object]>7 },[object Object]>8 afterSwap(newNode, meta) {[object Object]>9 return drawer.open();[object Object]>10 },[object Object]>11 };[object Object]>12</script>the beforeSwap hook fires before the old content is removed. use it to animate the old content out. the afterSwap hook fires after the new content has been inserted. use it to animate the new content in.
per navigation transitions
you can override the global hooks for a single navigation by passing a transitions object to dSPA.navigate().
1dSPA.navigate('/' + tab.dataset.tab, {2 targets: ['#tabContent'],3 transitions: {4 beforeSwapTarget(oldEl, meta) {5 oldEl.style.transform = 'translate3d(0, 5%, 0)';6 oldEl.style.opacity = '0';7 },8 afterSwapTarget(newEl, meta) {9 newEl.style.transform = 'translate3d(0, 0, 0)';10 newEl.style.opacity = '1';11 }12 }13});
hook reference
four hooks are available. each receives two arguments: the dom element being swapped, and a meta object containing url, link, src, dest, and mode.
beforeSwap(oldNode, meta): fires once before the swap begins, receives the current page's target elementbeforeSwapTarget(oldNode, meta): fires once per target selector before that specific target is swappedafterSwap(newNode, meta): fires once after the swap is complete, receives the new content elementafterSwapTarget(newNode, meta): fires once per target selector after that specific target has been swapped
if a hook returns a promise, the router waits for it to resolve. a global safety timeout of one second prevents a stuck animation from blocking navigation indefinitely.
forms
basic usage
<d-form> is a custom element that submits forms via fetch instead of a full page navigation. it wraps a standard <form> element internally and intercepts its submit event.
1<d-form action="/register" method="POST" class="flex-column g-1">2 <input type="text" name="username" placeholder="username">3 <input type="password" name="password" placeholder="password">4 <button type="submit">register</button>5</d-form>the element automatically injects the csrf token into every request. if you already have a <form> element inside the <d-form>, the element will use it directly. if you omit it, a <form> is created for you using the method and action attributes from the <d-form>.
all form inputs are disabled during submission to prevent duplicate requests.
validation
you can define client side validation rules using the rules attribute. the syntax mirrors the server side validation rules.
1<d-form action="/register" method="POST" rules='{"invite_token": "required"}'>2 <input type="text" name="invite_token" placeholder="invite token">3 <button type="submit">submit</button>4</d-form>the available client side rules are: required, string, number, integer, boolean, array, email, min:value, max:value, in:a,b,c, not_in:a,b,c, regex:pattern, and nullable.
when validation fails, the form adds an error class to the nearest .input-wrapper ancestor (or the input itself) and inserts an error-message element below it. the errors are cleared automatically on the next submission attempt.
json responses
when the server responds with json, the form inspects the response body for specific properties.
if the response contains a redirect property, the form navigates to that url via the spa router. if the response also contains a force: true property, the form performs a full page redirect instead.
if the response contains an errors object, the form displays each error message next to its corresponding input field, following the same convention as client side validation.
html responses
when the server responds with html, the form replaces the content of its target element with the response. the target defaults to body but can be overridden using the target attribute.
1<d-form action="/search" method="GET" target="#results">2 <input type="text" name="q" placeholder="search">3 <button type="submit">search</button>4</d-form>if the navigate attribute is present, the form uses the spa router's full navigation pipeline instead of a simple content replacement.
1<d-form action="/search" method="GET" target="#results" navigate>2 <input type="text" name="q" placeholder="search">3</d-form>
redirect handling
if the server responds with an http redirect (3xx), the form follows it through the spa router, swapping the new page content into the default targets and updating the browser url.
force reload
the force-reload attribute tells the form to trigger a full page reload under certain conditions instead of handling the response through the spa router.
1<!-- always reload after submission -->2<d-form action="/save" method="POST" force-reload>3 4<!-- reload only on success -->5<d-form action="/register" method="POST" force-reload="success">6 7<!-- reload only on error -->8<d-form action="/save" method="POST" force-reload="error">the valid values are true (or empty string, which is equivalent), all, success, and error.
callbacks
you can execute a javascript function after a successful response by specifying its name in the callback attribute. the function receives the parsed response data as its argument.
1<d-form action="/api/magic" method="POST" callback="onMagicSuccess">2 <button type="submit">generate</button>3</d-form>4 5<script>[object Object]>6 function onMagicSuccess(data) {[object Object]>7 // handle the response[object Object]>8 }[object Object]>9</script>
programmatic submission
you can submit a form programmatically by calling .submit() on the <d-form> element.
1select('d-form').submit();you can also retrieve the current form data as a FormData object by calling .serialize().
1const data = select('d-form').serialize();
script execution
the router executes all <script> tags found inside swapped content after each navigation. every script runs inside an isolated scope that provides lifecycle aware replacements for timers, listeners, fetch, and animation frames. when the content that owns the script is removed from the dom during a subsequent navigation, all of its timers, listeners, and pending requests are automatically cleaned up.
scoped scripts
inline scripts inside swapped content receive scoped versions of standard browser apis as local variables. you use them exactly as you would their global counterparts, but they are automatically torn down when the content is navigated away from.
the following scoped apis are available inside every inline script:
setTimeout,setInterval,clearTimeout,clearIntervalrequestAnimationFrame,cancelAnimationFramefetchlisten(target, event, handler, options): scopedaddEventListenerlistenAll(targets, event, handler, options): scopedaddEventListeneron multiple elementsunlisten(target, event, handler, options): manualremoveEventListenerunlistenAll(targets, event, handler, options): manualremoveEventListeneron multiple elementsnextFrame(): returns a promise that resolves on the next animation framesleep(ms): returns a promise that resolves after the specified delaydSPA_SCOPE: the raw scope object, for advanced usageSocket: the framework's socket facadedSPA: the router itself
1<div id="counter">0</div>2<script>[object Object]>3 let count = 0;[object Object]>4 const el = document.querySelector('#counter');[object Object]>5[object Object]>6 setInterval(() => {[object Object]>7 count++;[object Object]>8 el.textContent = count;[object Object]>9 }, 1000);[object Object]>10</script>when the user navigates away, the interval is automatically cleared. no manual cleanup is required.
persistent scripts
scripts that need to survive navigation cycles use the type="text/dspa" attribute. these scripts are not executed during the initial page load. instead, they are picked up and executed by the router after the dom is ready and the websocket connection is established. they receive the same scoped apis as regular scripts.
1<script type="text/dspa">[object Object]>2 Socket.on('update:level', (data) => {[object Object]>3 selectAll('[data-level]').forEach(el => {[object Object]>4 el.textContent = data.level;[object Object]>5 });[object Object]>6 });[object Object]>7</script>use text/dspa for scripts that depend on the socket connection or that should persist across spa navigations within the same layout.
module scripts
es module scripts (type="module") are supported. by default, module scripts do not receive the scoped api variables because module scope isolation prevents the wrapping technique used for classic scripts.
to opt a module script into scope injection, add the data-dspa-scope attribute.
1<script type="module" data-dspa-scope>[object Object]>2 const el = document.querySelector('#timer');[object Object]>3 setInterval(() => {[object Object]>4 el.textContent = new Date().toLocaleTimeString();[object Object]>5 }, 1000);[object Object]>6</script>
external scripts
external scripts (those with a src attribute) are loaded and executed once. on subsequent navigations, the router recognizes that the script has already been loaded and skips it. this prevents duplicate library initialization.
to force an external script to reexecute on every navigation, add the rerun-scripts attribute to the <d-link> or pass rerunScripts: true to dSPA.navigate().
you can manually mark an external script as loaded using dSPA.markExternalScriptLoaded(src) to prevent it from being fetched again.
to prevent the router from executing a specific script tag entirely, add the data-dspa-ignore attribute.
1<script src="/analytics.js" data-dspa-ignore></script>
active navigation
the router provides a declarative way to highlight the currently active navigation link. add the d-nav-active attribute to any element to specify which css classes should be toggled based on the current url.
1<d-link[object Object]>2 href="/home"[object Object]>3 d-nav-url="/home"[object Object]>4 d-nav-active="d-link-active"[object Object]>5 class="text-content bdr hover:bg-container-l tr-03 px-1 py-075 flex-center">6 <i class="dstrn-home"></i>7</d-link>d-nav-url specifies the url path this element corresponds to. the router compares the current window.location.pathname against this value. if the path starts with the value, the classes listed in d-nav-active are added. if it does not match, the classes are removed.
by default, the matching is prefix based. a d-nav-url of /home will also match /home/settings. to require an exact match, add the d-nav-exact attribute.
1<d-link[object Object]>2 href="/home"[object Object]>3 d-nav-url="/home"[object Object]>4 d-nav-active="d-link-active"[object Object]>5 d-nav-exact>6 home7</d-link>the active state is recalculated after every spa navigation automatically.
csrf token management
the router automatically manages csrf tokens across navigations. when a page is fetched, the router extracts the csrf-token and shield-challenge meta tags from the response and updates the current page's meta tags with the new values.
all mutating fetch requests (POST, PUT, PATCH, DELETE) that target the same origin automatically have the X-CSRF-TOKEN header injected. this applies to both the global fetch() function and the scoped fetch() provided to inline scripts. you never need to manually attach a csrf token to your client side requests.
dom morphing
during a swap, the router does not blindly replace the old dom with the new dom. it uses a morphing algorithm that walks both trees and applies the minimum number of changes needed to transform the old tree into the new one. this preserves focus state, input values, scroll positions within nested containers, and any runtime state attached to dom nodes that did not change.
the morphing algorithm handles element attribute updates, text node changes, child reordering by id, and input/select/textarea value synchronization.
custom elements that are registered with customElements.define are properly upgraded after morphing. if a custom element is encountered, the old element is fully replaced with the new one and upgraded via customElements.upgrade().
scope lifecycle
every time the router executes scripts inside swapped content, it creates a scope. the scope owns all timers, animation frames, fetch requests, and event listeners created by those scripts. when the dom node that owns the scope is removed (either by a subsequent navigation or by direct dom manipulation), a MutationObserver detects the removal and automatically tears down the scope, clearing every timer, aborting every pending fetch, and removing every event listener.
this means you never need to write cleanup code in your page scripts. the router handles it for you.
scroll behavior
by default, the router scrolls to the top of the page after every navigation. you disable this behavior per navigation by adding the preserve-scroll attribute to a <d-link> or by passing preserveScroll: true to dSPA.navigate().
debug mode
in the local environment, the framework automatically enables debug mode on the router. debug mode logs detailed information about each navigation, swap timing, scope creation and cleanup, and potential memory leaks.
the router tracks how many navigations each scope has survived. if a scope survives more than five navigations without being cleaned up, a warning is logged indicating a potential memory leak. orphan scopes (scopes no longer linked to any dom node) are also reported.
you can manually toggle debug mode.
1dSPA.debug = true;
