Unmatched URLs (404)
When no registered state matches the browser URL, UI-Router does nothing by default: the URL sits in the address bar and no view renders. The urlService.rules.otherwise rule lets you route those URLs somewhere deliberate — a 404 state, or a redirect back home.
The sample app implements the 404-state approach; this page walks through that implementation (router.config.ts, states.ts).
The rule
otherwise() only fires after every registered URL rule has failed to match, so it can never shadow a real state:
urlService.rules.otherwise(() => ({
state: 'notFound',
params: { attemptedPath: urlService.path() },
}));Passing the attempted path along as a param lets the 404 view show what failed to match. If you'd rather silently return home, target your welcome state instead — or use the string shorthand, otherwise('/welcome').
The 404 state
export const notFoundState = {
parent: 'app',
name: 'notFound',
params: { attemptedPath: null },
resolve: [
{
token: 'attemptedPath',
deps: ['$transition$'],
resolveFn: ($transition$: Transition) => $transition$.params().attemptedPath,
},
],
component: NotFound,
};The state intentionally declares no url: the unmatched URL stays in the address bar (like a server-rendered 404 page), and the state can only be activated by the rule. The view is an ordinary component that reads the resolved path:
export default (props: UIViewInjectedProps<NotFoundResolves>) =>
html`<div class="container-fluid not-found">
<h3>404 Page Not Found</h3>
<p>No state matched the URL <code>${props.resolves.attemptedPath}</code>.</p>
<button ${uiSref('welcome')} class="btn btn-primary">Return to Welcome</button>
</div>`;Caveat: lazy-loaded future states
If your app lazy loads modules through future states (name: 'contacts.**'), there is one gap otherwise() cannot cover on its own. A URL like /contacts/no/such/page does match the contacts.** wildcard, so the future state's module lazy loads — and if nothing in the loaded module matches either, @uirouter/core re-syncs the URL (correctly starting the notFound transition) but then lets the original transition resume, re-activating the already-replaced .** placeholder and superseding the 404.
Until that's addressed upstream, a low-priority hook that aborts transitions to deregistered placeholders closes the gap:
// runs after the core lazyLoad hook (priority 0); aborts transitions to
// placeholders that lazy loading has already replaced in the registry
router.transitionService.onBefore(
{ to: (state) => !!state?.name.endsWith('.**') },
(transition) =>
transition.router.stateRegistry.get(transition.to().name ?? '') === null
? false
: undefined,
{ priority: -10 },
);With the rule, the state, and the hook in place, all three cases behave: plain garbage URLs render the 404 view, future-state URLs still lazy load, and URLs left unmatched after a lazy load land on the 404 view too. The sample app's not_found.cy.js Cypress spec pins each case.