Custom Elements and Hash Routing

Routing is one of the most powerful tools in a frontend developer’s toolbox — but when you’re working inside Liferay, using it safely can get tricky. Traditional hash-based routing assumes it owns the address bar, which breaks down fast when multiple apps share the same page. In this post, I walk through a new utility (Liferay.Routing) and a companion <LiferayRouter /> React adapter that make hash routing safe, scoped, and fully Liferay-compatible — even across multiple custom elements, frameworks, and apps. We’ll also explore what it would take to bring the same integration to Angular.

If you’ve ever heard me talk about building single-page apps inside Liferay, chances are good you’ve also heard me discourage the use of things like React’s <HashRouter>. It’s not that I have anything against hash routing in general — in fact, it works quite well for many standalone apps. But the problem is that Liferay owns the address bar. That means when multiple apps try to manipulate the hash portion of the URL (window.location.hash), they can — and often do — end up stepping on each other.

Despite my usual advice, developers continue to ask about using hash-based routing. And I get it. Hash routing gives you a lot of benefits — the ability to share URLs, deep-link into application state, and even preserve navigation with bookmarks or reloads. In many cases, developers have been able to make it work just fine as long as they’re careful and the use case is tightly scoped.

But over time, I came to accept that the need for hash-based routing isn’t going away. And that led me to a question: If Liferay already owns the address bar, why can’t it also own hash routing?

Turns out… it can.

Because I wrote it. 😄 And now I’m going to share it with you and walk you through exactly how it works and how you can use it too.

Routing, the Liferay Way

Before we get into the new routing utility, let’s take a quick refresher on how routing works in Liferay.

In Liferay, the primary function of the URL is to identify a page. That’s it. The path portion of the URL (like /web/guest/home) maps to a layout in the system, not an application route, data ID, or view state. Everything beyond that — app state, user input, navigation context — is typically handled in the browser.

That’s why frameworks often rely on the hash (#) portion of the URL. Liferay itself doesn’t do much with it, the hash is generally ignored by Liferay.

This makes the hash attractive for client-side routing. But it also means there’s no coordination — if two different custom elements or apps on the same page try to use the hash for their own purposes, they’ll likely interfere with each other.

That’s the challenge: how do we bring coordination to this unclaimed part of the URL?

To solve this, I created a small JavaScript utility in a file named liferay-routing.js. It’s designed to be simple, framework-neutral, and capable of handling routing safely even when multiple components are present on the same page.

So how does it work?

Instead of assuming the entire hash belongs to one app, this utility splits the hash into named segments, one per component or routing context. For example, imagine you’ve got two components on the page — maybe one is a user settings panel and the other is a product viewer. Rather than fighting over a shared hash, each component can manage its own part:

#user=/settings&viewer=/product/42

Now, you might notice something if you look at it in your address bar — the slashes in those paths get encoded. So what you’ll actually see is:

#user=%2Fsettings&viewer=%2Fproduct%2F42

That’s totally expected. The utility handles the encoding and decoding transparently, so your logic always works with the “real” path strings, while the browser handles the encoded values under the hood.

Internally, the utility manages a small set of tools per key:

  • A routing stack so each key has its own history (push, replace, go(n))

  • Listeners so components can react to route changes

  • Location parsing so you can break down a path into pathname, search, and hash parts

Best of all, it doesn’t care what framework you’re using. You could use this in a plain JavaScript app, a Vue component, a React widget — it doesn’t matter. The routing behavior is fully scoped and safe to use across the board.

You can call it directly via the global Liferay.Routing:

Liferay.Routing.push('alpha', '/settings');

Or, if you’ve set it up as a JS Import Map client extension, you can import it like this:

import Routing from 'liferay-routing';

Routing.replace('beta', '/product/42');

And listening for changes is just as simple:

Routing.listen('alpha', (location) => {
  console.log('New route:', location.pathname);
});

No more global hash collisions. No more guesswork.

Making It Work in React: <LiferayRouter />

Of course, the next step was bringing this into the React world — particularly React 18.

If you’ve used React Router before, you’ve probably reached for <HashRouter /> or <MemoryRouter /> at some point. And to be clear — these can work inside Liferay. They’re not unsafe, and in fact, if your component doesn’t need deep linking or isn’t sharing the page with other routers, they might be totally fine.

But they also have some limitations. With <HashRouter />, you only get one hash in the URL — so if multiple components try to use it, they’ll collide. And with <MemoryRouter />, everything lives in memory, so you lose the ability to deep link or refresh into a particular view.

If you’re building a truly isolated component, and you don’t care about URL synchronization or route sharing, those routers still work. But if you want something that plays nicely with the browser, supports deep links, and lets multiple apps live side-by-side without interfering with each other — that’s where <LiferayRouter /> comes in.

This is a lightweight component that acts as a bridge between React Router and Liferay.Routing, but it conforms completely to the React Router usage patterns. Instead of relying on a global hash or memory-based routing, it scopes all routing behavior to a specific routingKey — so you can use multiple routers on the same page without any interference as long as each uses a unique routingKey.

Here’s what a simple setup might look like:

<LiferayRouter routingKey="alpha">
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/settings" element={<Settings />} />
    <Route path="/product/:id" element={<Product />} />
  </Routes>
</LiferayRouter>

With this, you’re using React Router exactly as you normally would — including <Link>, useParams(), useSearchParams(), and even useNavigate() — but the routing state is isolated by the key "alpha", and stored safely in the hash like this:

#alpha=%2Fproduct%2F42

What Features Are Supported?

This might surprise you, but <LiferayRouter /> supports pretty much everything you expect from React Router.

Here’s what we’ve tested (and confirmed working):

  • ✅ Dynamic route parameters — things like /product/:id work exactly as expected.

  • ✅ Search/query parameters — use useSearchParams() to read ?q=keyword values.

  • ✅ Wildcard routes — /blog/* and similar paths are fully supported.

  • ✅ Nested routes — including <Outlet /> and index routes.

  • ✅ Programmatic navigation — useNavigate() just works.

  • ✅ Multiple router instances — drop two of these on the same page with different routingKeys, and both will run independently and safely.

Try It Yourself

If you want to try this out yourself, everything is available on GitHub:

📦 https://github.com/dnebing/advanced-custom-element-techniques

Check out the /client-extensions folder, where you’ll find two projects:

  1. liferay-router — the routing utility itself

  2. liferay-react-router-test — a standard web component that uses <LiferayRouter /> to render routes

You can build and deploy them using the Blade CLI:

$ cd client-extensions/liferay-router
$ blade gw clean deploy

And then:

$ cd ../liferay-react-router-test
$ blade gw clean deploy

The liferay-react-router-test is a simple custom element. It doesn't do much beyond demonstrating all of the aspects of routing using the <LiferayRouter /> component, but it does demonstrate how to include, how to use, and how to deploy.

Using This in Your Own Custom Elements

So let’s say you’ve seen how all of this works and you’re ready to start using scoped hash routing in your own React-based custom elements.

Here’s what you need to do.

Step 1: Deploy the Routing Foundation

Before anything else, you need to make sure that the core routing utility is available in your environment. That means deploying the liferay-router client extension — the one that provides the Liferay.Routing global.

You only need to deploy this once, and it will be available for all of your client-side apps to use at runtime.

Step 2: Add Routing Support to Your Custom Element

Now, for each custom element where you want to support scoped hash routing, you’ll need to copy the LiferayRouter.jsx file from the liferay-react-router-test project into your own codebase.

This file contains the React integration component that wires up React Router to Liferay.Routing.

Once it’s in your project, you can use it just like any other router:

import { Routes, Route } from 'react-router-dom';
import LiferayRouter from './LiferayRouter.jsx';
import Main from './Main.jsx';
import Details from './Details.jsx';

...

return (
<LiferayRouter routingKey="my-widget">
  <Routes>
    <Route path="/" element={<Main />} />
    <Route path="/details/:id" element={<Details />} />
  </Routes>
</LiferayRouter>
);

You could, in fact, test locally outside of Liferay using <HashRouter /> or <MemoryRouter /> and then change to the <LiferayRouter /> after it checks out and before you deploy to Liferay.

A Few Notes About Dependencies

The LiferayRouter.jsx file depends on react-router-dom, so your project will need to have that installed — which is probably already the case if you’re using React Router.

It also depends on the liferay-router client extension at runtime, but not during your build. So you don’t need to bundle or resolve it statically — just make sure it’s deployed in your Liferay instance and available as a JS import map or global.

There’s nothing magic or environment-specific about the React part. It’s just a clean adapter that lets you use React Router with per-component scoped hash routing, all handled by the global Liferay.Routing.

Demonstrating By Wrapping It in a Fragment

To make things easier and to test, I created a custom fragment called DynaRouter. This fragment wraps the test component and passes the routerKey using the props pattern described in the Advanced Custom Element Techniques post.

The Configuration panel for the fragment contains:

{"fieldSets": [ {
  "fields": [{
    "dataType": "string",
    "defaultValue": "alpha",
    "label": "Router Key",
    "name": "routerKeyConfValue",
    "description": "Router Key to pass to the custom element",
    "type": "text",
    "typeOptions": {
      "placeholder": "Enter a router key"
    }
  }],
  "label": "Routing Config"
} ] }

And the HTML panel contains:

<div class="fragment_11">
  <liferay-router-test routerKey="${configuration.routerKeyConfValue}" />
</div>

You can drop two of these DynaRouter fragments onto a page, assign one routerKey="alpha" and the other routerKey="beta", and they’ll both behave independently.

Putting It All Together

I created a new page, Routing, and I dropped two DynaRouters on the page and gave them the alpha and beta routerKeys.

If you create the same page and publish it, you can visit the following URL:

http://localhost:8080/routing#alpha=%2Fsettings%2Fprofile&beta=%2Fnavigate

Here’s what’s happening:

  • The component with routerKey="alpha" is showing the settings profile view.

  • The component with routerKey="beta" is showing a panel that uses programmatic navigation.

Both routes are encoded in the hash. You can bookmark that URL, share it, or refresh the page — and both components will pick up exactly where they left off.

What About Angular?

If you’re building a custom element using Angular and want to support scoped hash-based routing, you’re in luck. Angular supports hash routing natively via its HashLocationStrategy, but it doesn’t scope the hash per component — it expects to own everything after the #.

That’s a problem when you have multiple Angular (or React or Vue) components on the same page, each wanting to manage its own route. That’s exactly why we created Liferay.Routing: to provide a shared, structured way to split up the hash so each component gets its own slice — and they don’t interfere with each other.

To integrate Angular with Liferay.Routing, we can implement a custom LocationStrategy and read the routerKey dynamically from the custom element’s attributes.

A Custom LocationStrategy for Liferay.Routing

Angular allows you to override its routing behavior by providing your own LocationStrategy. Here’s what a LiferayHashLocationStrategy might look like — simplified and designed to read the routerKey directly from the DOM:

import { Injectable } from '@angular/core';
import { LocationStrategy } from '@angular/common';

declare const Liferay: any;

@Injectable()
export class LiferayHashLocationStrategy extends LocationStrategy {
  private internalPath = '/';
  private routerKey = 'default';

  constructor() {
    super();

    // Grab the routerKey from the element attribute
    const element = document.querySelector('your-angular-custom-element-tag');
    if (element?.hasAttribute('routerKey')) {
      this.routerKey = element.getAttribute('routerKey')!;
    }

    Liferay?.Routing?.listen?.(this.routerKey, (location) => {
      this.internalPath = location.pathname + location.search + location.hash;
    });
  }

  path(): string {
    return this.internalPath;
  }

  prepareExternalUrl(internal: string): string {
    return Liferay?.Routing?.createHref?.(this.routerKey, internal) ?? internal;
  }

  pushState(state: any, title: string, path: string, query: string): void {
    const fullPath = query ? `${path}?${query}` : path;
    Liferay?.Routing?.push?.(this.routerKey, fullPath);
  }

  replaceState(state: any, title: string, path: string, query: string): void {
    const fullPath = query ? `${path}?${query}` : path;
    Liferay?.Routing?.replace?.(this.routerKey, fullPath);
  }

  forward(): void {
    Liferay?.Routing?.go?.(this.routerKey, 1);
  }

  back(): void {
    Liferay?.Routing?.go?.(this.routerKey, -1);
  }

  onPopState(fn: (event: any) => void): void {
    Liferay?.Routing?.listen?.(this.routerKey, () => fn(null));
  }
}

Registering the Strategy in Angular

Once you’ve written your strategy, wire it up in your AppModule:

@NgModule({
  providers: [
    { provide: LocationStrategy, useClass: LiferayHashLocationStrategy }
  ]
})
export class AppModule {}

Now, when your Angular app initializes, it will read the routerKey from its DOM element (like we did in React) and scope its routing behavior accordingly.

You’ll still define routes using Angular’s RouterModule, use [routerLink] in templates, and inject ActivatedRoute to access route params — all of that stays the same. The only difference is that instead of Angular owning the hash, it now cooperates with Liferay’s hash manager.

What Else You Could Improve

This gives Angular apps the same scoped routing power we’ve already built for React — all while keeping the logic lightweight, framework-aligned, and compatible with Liferay’s expectations.

If you’re using Angular in your client extensions and want to support per-component routing, this pattern is ready to drop in and adapt to your needs. Want to take it further? You could extract the strategy into a reusable library or fragment for consistent use across Angular-based custom elements.

The problem, though, is that this code isn't complete. It's a pretty decent mockup of how it would to do it, but it will take some further revision to make it truly ready to use and test.

Final Thoughts

This approach solves one of the long-standing pain points of supporting hash routing in Liferay: making routing reliable, shareable, and safe across multiple components and frameworks.

By giving Liferay ownership of the hash, and exposing a clean, modern API through Liferay.Routing, we can finally use hash-based routing without fear of collision or instability.

And if you’re using React? You don’t even have to think about it. Just drop in <LiferayRouter />, give it a key, and keep building.

As always, I’m excited to see how this gets used and extended. If you try it out or have ideas for making it better, feel free to open a discussion or reach out.

Until then — happy routing. 🚀

A Quick Word of Caution

Before we wrap up, a quick note about limits.

Because this library encodes routing state in the URL hash, you do technically have the ability to pack a lot into it. And while that flexibility can be powerful, it also comes with some trade-offs.

  • Long URLs can be fragile — browsers have varying limits on URL length, and very long hashes can behave unpredictably or break when copied, shared, or bookmarked.

  • User experience matters — a URL filled with dozens of characters or serialized state is hard to read and harder to debug.

  • Unexpected Cache Misses — The hash is often considered as part of the URL for caching appliances and CDNs, so leveraging hashing can lead to cache misses and more traffic getting to your application servers.

The liferay-routing.js utility makes scoped hash routing possible — but it doesn’t enforce discipline. That’s up to you. Just like with any routing strategy, you’ll want to be thoughtful about what you expose, how you encode it, and what a user is likely to see or share.

An Even More Important Word of Caution

While this hash routing support opens up some powerful capabilities for your custom elements, I want to be crystal clear about something: this is not meant to replace Liferay’s page navigation.

If your application spans multiple Liferay pages, you should be using Liferay’s built-in navigation and routing mechanisms to handle that — not trying to simulate full page transitions using a single React SPA. That’s not what this is for, and it’s not the right architectural fit for Liferay.

Think of this scoped hash routing as something for managing small, localized state within a single custom element.

A common example in React would be switching between a list view and a detail view inside a single widget. You might have a list of products or users, and when someone clicks an item, you update the route to something like /product/123 so the detail view loads — all without navigating away from the page. That’s a great use case.

But trying to build a full application with site-wide navigation, deep hierarchies, or layouts driven entirely by React routing? That’s not only going to fight with Liferay — it’ll likely break under real-world use.

Use this for what it’s intended to do: small, safe, component-scoped routing, designed to play nicely with Liferay’s existing structure.