Blogs
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
routingKey
s, 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:
-
liferay-router
— the routing utility itself -
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.