Deep Links That Resolve Themselves
Deep linking into a single-page application is rarely as simple as pointing a URL at a page. In Cloudflare’s Dashboard, the challenge is compounded by the fact that the most useful resources live behind dynamic context — a user’s account and zone. A support doc can’t hardcode a URL to a specific zone’s Page Rules tab if it doesn’t know which zone that is. And even when the right destination can be assembled, the page itself may change after initial render, making a static anchor unreliable.
Cloudflare’s solution has three parts: a URL schema that expresses placeholders for dynamic values, a DeepLink routing component that resolves those placeholders, and a ScrollAnchor component that waits for the page to settle before scrolling to a specific piece of content.
The URL Schema
To avoid requiring a linker to know a user’s account or zone ahead of time, deep links use a to query parameter that contains a path into the Dashboard. Placeholders prefixed with : represent values the resolver must supply:
dash.cloudflare.com/?to=/:account/:zone/ssl-tls/edge-certificates
This link points to the “Edge Certificates” section of the “SSL-TLS” product for “some” account and “some” zone that the user will need to resolve interactively. Once resolved, the URL is transformed into a standard Dashboard path:
dash.cloudflare.com/<resolvedAccount>/<resolvedZone>/ssl-tls/edge-certificates
The same schema works for account-level resources, where only the account needs resolution:
dash.cloudflare.com/?to=/:account/billing
Linkers can also provide known values to reduce friction. This link targets the “Traffic” product tab for any zone inside account 1234567890abcdef:
dash.cloudflare.com/?to=/1234567890abcdef/:zone/traffic
Resolving Placeholders
The Dashboard is a single-page React app using React Router. Normally, a page load triggers a cascade of API calls and component renders. But when a deep link is being resolved, the framework blocks React Router entirely. This prevents unnecessary API calls and DOM updates while the framework iterates over the parsed URL path, building a concrete URL by resolving each placeholder.
Each dynamic symbol has its own resolver — a function that is async, takes a context parameter, and always returns a string. The async signature lets resolvers pause on API calls. Every symbol starting with : is dispatched to its corresponding resolver; static path segments are appended as-is.
const RESOLVERS: Resolvers = {
account: accountResolver,
zone: zoneResolver
};
const resolvedParts: string[] = [];
// parts = [‘:account’, ‘:zone’, ‘traffic’]
for (let part of parts) {
if (part.startsWith(‘:’)) {
// for :account, accountResolver is awaited and returns “abc123”
// for :zone, zoneResolver is awaited and returns “testsite.io”
part = await RESOLVERS[part.slice(1)];
}
resolvedParts.push(part);
}
const finalUrl = resolvedParts.join(‘/’);
The resolver context is an object passed from DeepLink down to each resolver. It carries access to the Redux store, previously resolved values, the other parts of the deep link, and utilities for user interaction. This context is what allows a resolver to do more than just call an API — it can interact with the application itself.
Waiting for User Input
When a resolver cannot determine a value on its own — for example, when a user has multiple zones — it needs to hand control to the user. The context exposes unblockRouter and blockRouter, which toggle the state that gates the Router component. This allows the framework to show a selection page like the existing zone list without fully rendering the Dashboard:

To observe the user’s choice from inside an isolated resolver function, Cloudflare built waitForPageAction. It takes a pageToAwaitActionOn URL string and an actionType string, and returns a promise. The promise resolves with action metadata when the matching action happens on that page, or rejects if the user navigates away:
const zoneResolver: Resolver = async ctx => {
// ...
if (zones.length > 1) {
// delegate to React Router to render the page with zone picker
ctx.unblockRouter();
// need users help to pick a zone. Wait for ‘ZONE_SELECTED’ action at ‘dash.cloudflare.com/abc123’
// action is an object with metadata about zone. It contains zoneName, which can be used in this resolver to resolve :zone symbol
const action = ctx.waitForPageAction(
‘dash.cloudflare.com/abc123’,
‘ZONE_SELECTED’
);
// block the router again
ctx.blockRouter();
return action.zoneName
}
};
Internally, waitForPageAction leans on Redux’s store.subscribe(listener) API. Because the listener only receives the current state — not the dispatched action — Cloudflare added a simple reducer that stores every dispatched action in store.getState().lastAction. The listener then compares the current page and the last action’s type against the parameters. A mismatch means the user left the page; the listener unsubscribes and rejects the promise, stopping the resolver. A match means the action happened; the promise resolves with the action metadata and resolution continues:
export function waitForPageAction = (store: Store<DashState>) =>(
pageToAwaitActionOn: string,
actionType: string
) =>
new Promise<AnyAction>((resolve, reject) => {
// Subscribe to redux store
const unsubscribe = store.subscribe(() => {
const state = store.getState();
const currentPage = state.router.location.pathname;
const lastAction = state.lastAction;
if (currentPage !== pageToAwaitActionOn) {
// user navigated away -unsubscribe and reject
unsubscribe();
reject(‘User navigated away’);
} else if (lastAction.type === actionType) {
// Action types match! Unsubscribe and resolve with action object
unsubscribe();
resolve(lastAction);
}
});
});
A closely related utility, waitForAction, behaves identically but is not restricted to a specific page.
Scrolling to the Target
Once the URL is resolved and the page renders, ScrollAnchor handles the final leg. A client wraps content in ScrollAnchor and references it via a normal URL anchor:
<ScrollAnchor id=”super-important-setting-card”>
<SuperImportantSettingCard />
</ScrollAnchor>
dash.cloudflare.com/path/to/content#super-important-setting-card
A plain HTML id anchor would seem sufficient, but two problems get in the way. First, the Dashboard has a fixed header; content scrolled into view at the top of the browser window would end up hidden behind it. A CSS offset solves that with negative margins:
<div id=”super-important-setting-card” padding-top={headerOffset} margin-top={headerOffset}>
<SuperImportantSettingCard />
</div>
Second, the Dashboard’s DOM changes after page load. API responses re-render components, pushing anchored content out of view. The naive approach — scroll on page load — fires before the content has settled. Cloudflare considered two strategies for handling this.
The first is scrolling after a fixed delay. This is simple to implement but assumes a maximum page-load duration M that works for every page. If the DOM is still updating when the scroll fires, the user lands at the wrong vertical position. If it already settled, the scroll feels delayed and jarring.
The second, which Cloudflare adopted, scrolls after the DOM has “settled”. The algorithm defines a busyness threshold B (in milliseconds), starts a timer on page load, and resets it whenever it observes a DOM mutation. When the timer expires, the DOM has not changed for B milliseconds, so it is safe to scroll:
const SETTLE_THRESHOLD = 500;
const scrollThunk = (observer: MutationObserver) => {
scrollToAnchor(id);
observer.disconnect();
};
let domTimer: number;
const observer = new MutationObserver((_mutationsList, observer) => {
domTimer = resetTimeout(domTimer, scrollTunk, SETTLE_THRESHOLD, observer);
});
observer.observe(document.body, {childList: true, subtree: true});
domTimer = window.setTimeout(scrollThunk, SETTLE_THRESHOLD, observer);
Through trial and error across a sample of Dashboard pages, Cloudflare settled on a 500 millisecond threshold as sufficient for content to finish loading. The key assumption is that API calls resolve at roughly the same speed; if some fetches take notably longer than others, the algorithm may conclude the page has settled prematurely.
The result is a deep linking system that handles the full journey: an intuitive schema that can express unknown values, resolvers that interact with the user to fill those gaps, and an anchor that scrolls only when the page is truly ready.



