Frontend Interactivity and Client-Side Navigation
How BuddyNext renders interactive frontend views: the WordPress Interactivity API store model, in-place client-side navigation, the shared REST client, and the rules that keep a surface working after a navigation. This page is for developers building blocks, hub surfaces, or bridge plugins that add interactive markup.

Overview / Contract
Section titled “Overview / Contract”BuddyNext uses the official WordPress Interactivity API (@wordpress/interactivity) for all frontend reactivity. There is no React, no JSX, and no build step you have to run as a consumer: stores are plain ES modules registered against the runtime WordPress already ships, and templates wire behavior with data-wp-* attributes.
Three rules govern every interactive surface:
- Declarative by default. Controls bind to store actions and state through
data-wp-on--*,data-wp-bind--*, anddata-wp-textattributes. The Interactivity API re-hydrates these automatically, including after a client-side navigation, so they need no re-init code. - REST-only data. Every frontend read and mutation goes through the shared REST client (
restFetch), neveradmin-ajax.phpand never a scattered rawfetch(). The client centralizes the nonce, stale-nonce recovery, and error toasts. - Navigation-safe imperative code. Any unavoidable imperative setup is registered through
onNavReady()so it runs on initial load and again after every client-side swap. Code bound only toDOMContentLoadedsilently dies after the first navigation.
The full normative standard lives in docs/standards/frontend-interactivity.md (v1.0; reference implementation Jetonomy 1.5.0). This page documents how BuddyNext implements it.
Frontend JS entry points
Section titled “Frontend JS entry points”The shell module graph lives under assets/js/shell/ and is shared by every feature store:
| File | Role |
|---|---|
assets/js/shell/rest-client.js |
The single REST client. Exports restFetch; also exposes window.buddynextRest.restFetch for the block bundle. |
assets/js/shell/navigate.js |
Registers the bare buddynext store’s navigate action that swaps the router region. |
assets/js/shell/nav-init.js |
Exports onNavReady() - binds idempotent imperative init to load and to every client-nav. |
assets/js/shell/dialog.js |
bnToast, bnConfirm, bnPrompt, bnReportDialog - token-styled replacements for native alert/confirm. |
assets/js/shell/font-scale.js |
Pre-paint theme (data-bn-theme) and font-scale stamping; chrome-level, bound once. |
Feature stores live under assets/js/{feature}/store.js (for example feed, members, spaces, messages, search, profile, notifications, hashtags). Each is registered in AssetService and enqueued per hub by PageRouter::enqueue_hub_assets().
assets/js/blocks.jsis the EDITOR script (buddynext-blocks-editor), used bywp.serverSideRenderfor editor previews. It never loads on the front end, so any store it registers is inert at runtime. Front-end behaviour for a block comes from itsviewScriptModule- for the social buttons that is@buddynext/social-buttons(assets/js/social/follow-store.js). Do not copy a store out ofblocks.js.
Note: Store namespaces are always
buddynext/{feature-name}(for examplebuddynext/feed,buddynext/follow-button). The barebuddynextnamespace is reserved for the shell’snavigateaction.
The store pattern
Section titled “The store pattern”A store has two halves: state (getters, computed from context) and actions (event handlers, written as generator functions so they can yield async work). Templates supply per-instance data through data-wp-context and bind to the store by name with data-wp-interactive.
A small store binding
Section titled “A small store binding”This is the complete follow button - its template plus the front-end store in assets/js/social/follow-store.js. It is the minimal end-to-end shape: context in, computed class and label out, a single action that calls REST and flips context.
Template (templates/blocks/follow-button.php):
<div class="bn-block-follow-button" data-wp-interactive="buddynext/follow-button" data-wp-context="<?php echo esc_attr( $context_json ); ?>"> <button type="button" class="bn-btn" data-variant="<?php echo $is_following ? 'secondary' : 'primary'; ?>" data-size="sm" data-wp-on--click="actions.toggleFollow" data-wp-bind--class="state.buttonClass" data-wp-text="state.label" aria-pressed="<?php echo $is_following ? 'true' : 'false'; ?>" ><?php echo $is_following ? esc_html__( 'Following', 'buddynext' ) : esc_html__( 'Follow', 'buddynext' ); ?></button></div>$context_json is the JSON-encoded initial context, for example { "userId": 42, "isFollowing": false, "restUrl": "...", "nonce": "..." }.
Store (assets/js/social/follow-store.js, shipped as the @buddynext/social-buttons module):
import { store, getContext } from '@wordpress/interactivity';
store( 'buddynext/follow-button', { state: { // Variant, not a class-name string. Buttons are `bn-btn` plus a // `data-variant` attribute; the `bn-btn--*` modifiers this example // used to show were retired and no longer style anything. get followVariant() { return getContext().isFollowing ? 'secondary' : 'primary'; }, get label() { return getContext().isFollowing ? 'Following' : 'Follow'; }, }, actions: { *toggleFollow() { const ctx = getContext(); const method = ctx.isFollowing ? 'DELETE' : 'POST'; const res = yield window.buddynextRest.restFetch( '/users/' + ctx.userId + '/follow', { base: ctx.restUrl, nonce: ctx.nonce, method } ); if ( res.ok ) { ctx.isFollowing = ! ctx.isFollowing; // re-renders class + label } }, },} );Mutating ctx.isFollowing re-runs the buttonClass and label getters, so the button text and styling update with no DOM code. Because the binding is declarative, the same button keeps working after a client-side navigation.
Tip: Use computed
stategetters for every class and text binding. Do not write inline ternaries insidedata-wp-bindattributes - keep the logic in the store.
Client-side navigation
Section titled “Client-side navigation”Status: off by default, and not a user setting. Client-side navigation is an internal, staged-rollout feature controlled by the
buddynext_client_nav_enabledPHP filter (defaultfalse). There is no admin or UI toggle for it. While it is off,navigate.jsis not enqueued and the.bn-appshell renders as a normal server-rendered page, so every in-app click is a full-page load. Do not expect client-side navigation on a stock install. Flip the filter only for surfaces you have verified are navigation-safe (see “Keeping imperative code navigation-safe” below).
When client-side navigation is enabled, the .bn-app shell carries data-wp-interactive="buddynext" and data-wp-on--click="actions.navigate", and the main column is wrapped in a single router region: <main data-wp-router-region="buddynext/main">. Clicking an in-app link swaps only that region instead of reloading the document.
How it works (assets/js/shell/navigate.js):
- The
navigateaction intercepts same-origin left-clicks on<a>elements, calls@wordpress/interactivity-router(a dynamic dependency that downloads once and is reused), and dispatches abuddynext:navigatedevent after each swap. - The real
<a href>is always preserved as the fallback. JS-off, router errors, modified clicks (cmd/ctrl/shift/alt), new-tab/download links, cross-origin links, and deny-listed routes all degrade to a classic full-page load. - After a swap the action focuses the region, scrolls to top, and re-syncs active state on the persistent rail and mobile nav (which live outside the region, so their server-rendered active markers go stale).
The route deny-list
Section titled “The route deny-list”Navigation uses a deny-list, not an allow-list, so new routes are fast by default. Only rich-editor and security-sensitive routes full-load: profile edit, space settings/admin, single-post permalinks (/p/{id}/), membership checkout, and the auth/signup/verify/onboarding flows. Integration surfaces register their own deny entries through the buddynext_client_nav_deny filter, so a newly bridged plugin’s editor routes are respected without editing the shell.
Client-side navigation is gated behind the buddynext_client_nav_enabled filter and the window.bnShellData.clientNav switch (staged activation per surface). While off, navigate.js is not enqueued at all, every click falls through to a normal navigation, and the shell renders exactly as a static page.
Keeping imperative code navigation-safe
Section titled “Keeping imperative code navigation-safe”Region content swapped in by the router does not re-fire DOMContentLoaded. Bind any imperative setup through onNavReady() instead:
import { onNavReady } from '../shell/nav-init.js';
// Runs on initial load AND after every client-side navigation.onNavReady( function init() { // Idempotent: guard per-element work so re-running only wires new nodes. document .querySelectorAll( '.bn-thing:not([data-wired])' ) .forEach( ( el ) => { el.dataset.wired = '1'; // wire el ... } );} );
// Chrome that lives OUTSIDE the router region and must not re-run:onNavReady( setupFontScale, { once: true } );init must be idempotent: guard per-element work with a dataset flag, and install document-delegated listeners behind a single window flag.
The shared REST client
Section titled “The shared REST client”restFetch( path, opts ) is the only sanctioned way to call the API from the frontend. It:
- Resolves the base from
window.buddynextRestData.restBase(default/wp-json/buddynext/v1);opts.baseoverrides it for cross-namespace calls (for example the WPMediaVersemvs/v1routes the messages store uses). - Sends
X-WP-Nonceandcredentials: 'same-origin', and on a403 rest_cookie_invalid_noncerefreshes the nonce once via/auth/nonceand retries. - JSON-encodes plain-object bodies (FormData and Blob pass through so uploads keep their multipart boundary).
- Never throws - it always resolves to
{ ok, status, data, error? }. - Shows one generic error toast on failure unless the caller passes
{ toastOnError: false }(needed when you do optimistic rollback with your own toast).
import { restFetch } from '../shell/rest-client.js';
const res = await restFetch( '/posts', { method: 'POST', body: { content } } );if ( res.ok ) { // res.data is the created post}Notes / gotchas
Section titled “Notes / gotchas”Anti-patterns to avoid
Section titled “Anti-patterns to avoid”These all break after a client-side navigation, which is the failure mode the standard exists to prevent. A surface that works on full load but is dead or blank after a navigation is a failing surface.
- Per-route or per-view
wp_enqueue_script. The store module plus the router cover every view; per-route scripts do not re-run on a swap. wp_add_inline_script(..., 'after')or inline<script>driving region behavior. Inline scripts in a swapped fragment do not execute. Move the logic into the store or anonNavReady()init.DOMContentLoaded-only handlers that target region content. UseonNavReady()so they re-run after each swap.- Element-bound
el.addEventListener(...)on content that gets swapped, without a re-init. Prefer declarativedata-wp-on--*, or document-delegated listeners, oronNavReady(). - Raw
fetch()on the frontend with ad-hoc nonce handling. Route everything throughrestFetch; rawfetchis allowed only inside the REST client and the service worker. - Native
alert()/confirm(). UsebnToast/bnConfirmfromshell/dialog.js. windowglobals for stores (window.wp.interactivity.store(...)). Always importstorefrom@wordpress/interactivity.
Verification
Section titled “Verification”For every interactive surface, test the client-side path, not just a full load: full-load a different page, click a link to navigate to the surface, then exercise the control. It must behave identically to a full load. Code review looking clean is not sufficient.
Free and Pro boundary
Section titled “Free and Pro boundary”The shell module graph (REST client, navigate action, nav-init) is Free and shared. Pro features add their own stores under the same conventions and reuse the same restFetch client against the buddynext-pro/v1 namespace by passing opts.base. Pro does not ship a second REST client or its own router.

