Opening a modal is easy. Giving it a URL, and making that URL behave like a real part of the application, is where things become interesting.
Let’s look at three ways to connect a modal to the modern Angular Router, including the trade-offs that are easy to miss.
Why route a modal at all?
The usual modal starts with a click handler:
<button (click)="openEditDialog(product.id)">Edit</button>
That works, but the modal exists only in memory. The browser does not know about it.
As a result:
- refreshing the page closes it;
- the Back button may leave the page instead of closing it;
- the Forward button cannot reopen it;
- you cannot bookmark or share the modal;
- analytics cannot naturally distinguish the underlying page from the modal state.
Most of the times, that is completely fine. A confirmation dialog such as “Delete this draft?” probably does not need a permanent address.
But a product editor, login form, checkout step, image preview, or share panel may deserve one.
For the examples below, imagine a products page with an edit modal. I will use Angular Material’s MatDialog, but the routing strategies do not depend on Material. Near the end, we will connect the same idea to a plain HTML modal.
First rule: the URL as the source of truth
Before choosing a URL shape, we need one rule:
Navigation opens the modal, and another navigation closes it.
A common half-solution is to update the URL when the user clicks a button, then call dialog.open() separately. That creates two sources of truth. A deep link may update the URL without opening anything, while closing the dialog may leave a modal-looking URL behind.
Instead, our flow should be:
user action → router navigation → route state changes → modal opens
modal closes → router navigation → route state changes → modal stays closed
This also means the modal must react when route state changes because of Back, Forward, a redirect, or a pasted URL, not only because of our own button.
With that in mind, let’s choose where the modal state lives.
Option 1: a query parameter
The smallest change is to represent the modal with a query parameter:
/products?dialog=edit&productId=42
This says: “We are still on /products, with some optional UI state layered on top.”
Opening it
The trigger is a normal router link:
<a
[routerLink]="[]"
[queryParams]="{ dialog: 'edit', productId: product.id }"
queryParamsHandling="merge"
>
Edit
</a>
Using a link rather than a click handler gives us standard browser behaviour: users can open it in a new tab, copy its address, and use keyboard navigation.
Keeping MatDialog in sync
The products page observes the query parameters and owns the dialog instance:
import { Component, DestroyRef, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { distinctUntilChanged, map, take } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-products-page',
imports: [RouterLink],
templateUrl: './products-page.html',
})
export class ProductsPage {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
private dialogRef?: MatDialogRef<EditProductDialog>;
private openProductId?: string;
constructor() {
this.route.queryParamMap
.pipe(
map((params) => {
const isEditDialog = params.get('dialog') === 'edit';
return isEditDialog ? params.get('productId') : null;
}),
distinctUntilChanged(),
takeUntilDestroyed(),
)
.subscribe((productId) => this.syncDialog(productId));
}
private syncDialog(productId: string | null): void {
if (!productId) {
this.openProductId = undefined;
this.dialogRef?.close();
this.dialogRef = undefined;
return;
}
if (this.dialogRef && this.openProductId === productId) {
return;
}
this.dialogRef?.close();
this.openProductId = productId;
const dialogRef = this.dialog.open(EditProductDialog, {
data: { productId },
ariaLabel: 'Edit product',
});
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(take(1), takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
// Ignore a previous dialog closing after a new ID replaced it.
if (this.dialogRef !== dialogRef) {
return;
}
this.dialogRef = undefined;
this.openProductId = undefined;
// If Back already removed the parameter, do not navigate again.
if (this.route.snapshot.queryParamMap.get('dialog') !== 'edit') {
return;
}
this.router.navigate([], {
relativeTo: this.route,
queryParams: {
dialog: null,
productId: null,
},
queryParamsHandling: 'merge',
replaceUrl: true,
});
});
}
}
There is a little more code here than in the usual dialog.open() example because we are handling both directions:
- URL opens or replaces the dialog;
- removing the parameters closes it;
- closing it removes the parameters;
- closing it because Back already changed the URL does not trigger a second navigation.
That final check prevents a subtle history bug.
Why replaceUrl when closing?
Opening the modal creates a useful history entry:
/products
/products?dialog=edit&productId=42
If the user presses Back, Angular returns to /products and our subscription closes the dialog.
If the user clicks the modal’s close button, we remove the query parameters with replaceUrl: true. This avoids adding yet another entry just for the close operation.
History policy is a product choice, though. If moving from “open” to “closed” should itself be a revisit-able step, omit replaceUrl.
When query parameters work well
This approach is a good fit when:
- the modal is optional UI state on the current page;
- it may appear over several primary routes;
- you want a small routing change;
- filters, pagination, and the modal already coexist in the query string.
Its weaknesses are equally clear:
- the owning page needs modal-coordination code;
- query parameters are global to the URL, so naming collisions are possible;
- URLs become untidy if you encode a lot of modal data;
- the modal has no route lifecycle of its own.
Pass stable identifiers in the URL, not an entire object. /products?productId=42 can be refreshed; a JavaScript object passed only through navigation memory cannot.
Option 2: a child path
Maybe editing product 42 feels less like an optional flag and more like a nested destination:
/products/42/edit
That is a strong, readable URL. The trick is keeping the products page rendered underneath it.
We can make the edit route a child of the products page:
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'products',
component: ProductsPage,
children: [
{
path: ':productId/edit',
component: EditProductDialogRoute,
},
],
},
];
The parent template needs an outlet where Angular can activate that child route:
<h1>Products</h1>
<app-product-list />
<!-- The route component renders no visible page content here. -->
<router-outlet />
And the link becomes pleasantly ordinary:
<a [routerLink]="[product.id, 'edit']">Edit</a>
A route component that opens the dialog
Angular route configuration activates components; it does not execute arbitrary “open modal” functions. We therefore use a small route component as the bridge:
import { Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { distinctUntilChanged, map, take } from 'rxjs';
@Component({
selector: 'app-edit-product-dialog-route',
template: '',
})
export class EditProductDialogRoute {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
private dialogRef?: MatDialogRef<EditProductDialog>;
private closingBecauseRouteChanged = false;
constructor() {
this.route.paramMap
.pipe(
map((params) => params.get('productId')),
distinctUntilChanged(),
takeUntilDestroyed(),
)
.subscribe((productId) => {
if (!productId) {
this.router.navigateByUrl('/products', { replaceUrl: true });
return;
}
this.openDialog(productId);
});
this.destroyRef.onDestroy(() => {
this.closingBecauseRouteChanged = true;
const dialogRef = this.dialogRef;
this.dialogRef = undefined;
dialogRef?.close();
});
}
private openDialog(productId: string): void {
// Angular can reuse this route component when only productId changes.
// Invalidate the previous reference before closing it, so its
// afterClosed callback cannot navigate away from the new dialog.
const previousRef = this.dialogRef;
this.dialogRef = undefined;
previousRef?.close();
const dialogRef = this.dialog.open(EditProductDialog, {
data: { productId },
ariaLabel: 'Edit product',
});
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(take(1))
.subscribe(() => {
if (this.dialogRef !== dialogRef) {
return;
}
this.dialogRef = undefined;
if (!this.closingBecauseRouteChanged) {
this.router.navigateByUrl('/products', { replaceUrl: true });
}
});
}
}
Observing paramMap matters because Angular normally reuses the route component when only a parameter changes. Navigating directly from /products/42/edit to /products/43/edit does not run the constructor again; the subscription closes the old dialog and opens the new one.
The reference identity check then ignores the old dialog's late afterClosed() emission. Otherwise, replacing product 42 with product 43 could accidentally navigate back to /products.
The destruction handler covers a different case. If the user presses Back or navigates elsewhere while the dialog is open, Angular destroys the route component and the component closes the overlay.
The boolean prevents that programmatic close from sending the user back to /products after they have already navigated somewhere else. Without it, clicking a link to /orders could briefly navigate there and then be “corrected” back to /products by a late afterClosed() callback. Not fun.
Avoid hard-coding the parent when needed
The example navigates to /products for clarity. In a reusable feature, you may prefer relative navigation or an explicit return target.
Be careful with history.back() as a universal close implementation. It feels elegant when the modal was opened from /products, but a user may arrive directly at /products/42/edit. In that case, Back could leave your application entirely.
A deterministic parent URL is usually safer. If preserving the exact background state matters (filters included) encode that state in the URL or adopt the auxiliary-route approach below.
When a child path works well
Choose this approach when:
- the modal belongs to one clear parent page;
- the URL should read like a resource or operation;
- guards and resolvers should apply specifically to the modal;
- direct links such as
/products/42/editmake sense to users.
The trade-offs:
- the parent must remain active and expose a child outlet;
- a small route component is needed when using
MatDialog; - reusing the same modal over unrelated pages requires repeated child routes or a broader layout;
- the URL alone identifies the child destination, but not an arbitrary underlying page.
This option is often the nicest compromise for resource editors.
Option 3: an auxiliary route
Angular can activate more than one route at the same time. A shell component can expose a primary outlet for the page and a named outlet for the modal:
<router-outlet />
<router-outlet name="modal" />
That lets the URL describe both states independently:
/products(modal:edit/42)
The syntax is unusual at first, but the model is precise:
- primary route:
products; -
modaloutlet:edit/42.
Route configuration
Both routes should be children of the shell that owns those outlets:
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
component: AppShell,
children: [
{
path: 'products',
component: ProductsPage,
},
{
path: 'edit/:productId',
component: EditProductDialogRoute,
outlet: 'modal',
},
],
},
];
When a link is created inside ProductsPage, its current route is the primary child. The modal outlet, however, belongs to the parent shell. We can create the UrlTree relative to that parent:
import { ActivatedRoute, Router, UrlTree } from '@angular/router';
export class ProductsPage {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
editModalUrl(productId: string): UrlTree {
return this.router.createUrlTree(
[
{
outlets: {
modal: ['edit', productId],
},
},
],
{
relativeTo: this.route.parent,
queryParamsHandling: 'preserve',
},
);
}
}
<a [routerLink]="editModalUrl(product.id)">Edit</a>
Closing a named outlet means navigating with that outlet set to null, again relative to the shell:
this.router.navigate(
[
{
outlets: {
modal: null,
},
},
],
{
relativeTo: this.route.parent,
},
);
Our reactive EditProductDialogRoute can be reused here, including its paramMap subscription: changing /products(modal:edit/42) to /products(modal:edit/43) can reuse the same route component too. The only change is what happens after the dialog closes: instead of navigating to /products, it clears the modal outlet.
private closeOutlet(): void {
this.router.navigate(
[
{
outlets: {
modal: null,
},
},
],
{
relativeTo: this.route.parent,
queryParamsHandling: 'preserve',
replaceUrl: true,
},
);
}
As with the child-path version, the route component should close its MatDialogRef when it is destroyed and avoid navigating again when destruction was caused by an existing navigation.
Why auxiliary routes are special
With query parameters, we manually interpret a value as modal state.
With a child path, the modal belongs to a specific parent route.
With an auxiliary route, the Router natively represents the page and modal as two simultaneously active branches. This is powerful when the same modal can sit above different pages:
/products(modal:cart)
/account(modal:cart)
/search?q=headphones(modal:cart)
The primary route can change independently from the modal route, if that is what the application needs.
Auxiliary routes also provide a real route lifecycle. They can have:
- route parameters;
- guards;
- resolvers;
- lazy-loaded components;
- route-specific providers;
- their own activation and deactivation.
The cost
The URL is not subtle:
/products(modal:edit/42)
Developers unfamiliar with auxiliary routes may find the configuration and navigation syntax surprising. Relative navigation also requires care when named outlets are nested inside feature routes.
Use this model because you genuinely have two independent routing regions, not because it is the fanciest option available.
Material dialog or plain HTML?
So far, each routed component has opened a MatDialog. That works well because Material already handles the difficult modal mechanics: overlay positioning, backdrop, focus trapping, Escape, focus restoration, and ARIA roles.
But the Router does not care how the modal is rendered.
With a named outlet, for example, the routed component can be the modal:
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'app-edit-product-modal',
template: `
<div class="backdrop" (click)="close()">
<section
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="edit-product-title"
(click)="$event.stopPropagation()"
>
<h2 id="edit-product-title">Edit product</h2>
<app-product-form />
<button type="button" (click)="close()">Close</button>
</section>
</div>
`,
})
export class EditProductModal {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
close(): void {
this.router.navigate(
[{ outlets: { modal: null } }],
{
relativeTo: this.route.parent,
queryParamsHandling: 'preserve',
replaceUrl: true,
},
);
}
}
That example demonstrates the routing relationship, not a production-complete modal. A custom modal must also:
- move focus inside when it opens;
- trap focus while open;
- close on Escape when appropriate;
- restore focus when it closes;
- prevent background content from being interactive;
- expose a useful accessible name;
- handle scrolling correctly.
The native HTML <dialog> element or Angular CDK’s dialog and accessibility utilities can remove some of that work. A visually convincing <div class="modal"> is not automatically an accessible modal.
What about route inputs and signals?
Modern Angular can bind route state directly to component inputs with withComponentInputBinding(). That can make routed modal components pleasantly small:
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
],
};
export class EditProductModal {
readonly productId = input.required<string>();
}
This is especially useful for the child-path and auxiliary-route approaches because productId is a real route parameter.
For query parameters, direct input binding can also be convenient, but we still need coordination code if an imperative overlay such as MatDialog must open and close in response.
Signals improve how we consume route state; they do not change the architectural choice of where that state belongs in the URL.
A practical comparison
| Approach | Example URL | Best fit | Main drawback |
|---|---|---|---|
| Query parameter | /products?dialog=edit&productId=42 |
Optional UI state attached to the current page | Coordination lives in the page |
| Child path | /products/42/edit |
A modal that belongs to one parent resource or page | Parent layout and route bridge are required |
| Auxiliary route | /products(modal:edit/42) |
Page and modal are independent routed regions | More unusual URL and router syntax |
My default decision process is:
- If the modal is temporary and should not survive refresh, do not route it.
- If it is optional state of the current page, start with a query parameter.
- If it is a destination naturally nested under one page, use a child path.
- If it must coexist with several unrelated primary routes, consider an auxiliary route.
There is no universally “most Angular” answer. The URL should describe the product behaviour you want.
A few rules that apply to all three
Whichever option you choose:
- Treat the URL as the source of truth.
- Test pasted deep links, not only clicks from inside the app.
- Test Back and Forward while the modal is open.
- Close the overlay when its route is deactivated.
- Prevent
afterClosed()from overriding a navigation already in progress. - Put stable identifiers in the URL, then load the data from a service, store, or resolver.
- Decide deliberately whether closing should push or replace a history entry.
- Do not sacrifice modal accessibility just because routing works.
- Add an SSR-safe fallback or render routed modal content declaratively if the route can be rendered on the server.
That second-to-last point deserves repeating. A modal with a beautiful URL but broken keyboard focus is still a broken modal.
Final thought
Routing a modal is not really about calling open() from the Router. It is about deciding whether the modal is part of your application’s navigable state.
Once that decision is clear, the implementation becomes much easier: choose the URL shape that tells the truth, let navigation drive the UI, and make closing the modal a navigation too.
Top comments (0)