Opening a modal is straightforward. Attaching a URL to it—and making that URL act like a genuine part of the application—is where the complexity emerges.

Below are three strategies for wiring a modal into the modern Angular Router, along with the subtle trade-offs that often go unnoticed.

The case for routing a modal

The typical modal begins with a click handler:

<button (click)="openEditDialog(product.id)">Edit</button>

This works, but the modal lives solely in memory. The browser has no awareness of it.

Consequently:

  • refreshing the page dismisses it;
  • the Back button may navigate away from the page instead of closing it;
  • the Forward button cannot restore it;
  • it cannot be bookmarked or shared;
  • analytics cannot cleanly separate the base page from the modal state.

In many cases, that's perfectly acceptable. A confirmation prompt like "Delete this draft?" likely doesn't need a permanent address.

However, a product editor, login form, checkout flow, image viewer, or sharing panel might benefit from one.

For the upcoming examples, picture a products page with an edit modal. I'm using Angular Material's MatDialog, but the routing patterns are framework-agnostic. Toward the end, we'll apply the same concept to a plain HTML modal.

The guiding principle: URL as the single source of truth

Before settling on a URL structure, one rule must be established:

Navigation opens the modal, and navigation—not a direct call—closes it.

A frequent mistake is updating the URL on a button click and then separately calling dialog.open(). This yields two sources of truth. A direct link might alter the URL without opening anything, and dismissing the modal could leave a misleading URL in place.

Our flow should instead be:

user action → router navigation → route state changes → modal opens
modal closes → router navigation → route state changes → modal stays closed

This also implies the modal must respond to route changes triggered by Back, Forward, a redirect, or a pasted URL—not just our own actions.

With that foundation, let's decide where the modal state should live.

Approach 1: Employing a query parameter

The most minimal modification is to represent the modal through a query parameter:

/products?dialog=edit&productId=42

This conveys: "We're still on /products, with some optional UI state layered on top."

Triggering the modal

The opening mechanism is a standard router link:

<a
  [routerLink]="[]"
  [queryParams]="{ dialog: 'edit', productId: product.id }"
  queryParamsHandling="merge"
>
  Edit
</a>

Using a link rather than a click handler provides typical browser behavior: users can open it in a new tab, copy the address, and navigate via keyboard.

Synchronizing MatDialog

The products page subscribes to query parameters and manages 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 slightly more code than the standard dialog.open() example because we're covering both directions:

  • the URL opens or updates the dialog;
  • removing the parameters dismisses it;
  • closing it clears the parameters;
  • closing it *because Back already changed the URL* avoids a second navigation.

That last check prevents a subtle history-related bug.

The rationale behind replaceUrl on close

Opening the modal creates a useful history entry:

/products
/products?dialog=edit&productId=42

If the user hits Back, Angular reverts to /products and our subscription shuts the dialog.

If the user clicks the modal's close button, we strip the query parameters with replaceUrl: true. This prevents adding another history entry just for the closing action.

History handling is ultimately a product decision, though. If the shift from "open" to "closed" should itself be a backtrackable step, leave out replaceUrl.

Ideal scenarios for query parameters

This method suits cases where:

  • the modal is optional UI state for the current page;
  • it might appear over multiple primary routes;
  • you prefer a minimal routing change;
  • filters, pagination, and the modal already share the query string.

Its limitations are equally apparent:

  • the owning page must handle modal coordination logic;
  • query parameters are URL-global, so naming clashes are a risk;
  • URLs can become messy if you pack in too much modal data;
  • the modal lacks its own route lifecycle.

Favor stable identifiers in the URL over full objects. /products?productId=42 survives a refresh; a JavaScript object passed solely through navigation memory cannot.

Option 2: a child path

Editing product 42 might feel more like a nested destination than an optional flag:

/products/42/edit
Enter fullscreen mode Exit fullscreen mode

That results in a strong, readable URL. The important part is keeping the products page visible beneath it.

We can turn the edit route into 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,
      },
    ],
  },
];
Enter fullscreen mode Exit fullscreen mode

The parent template needs an outlet where Angular can render that child route:

<h1>Products</h1>

<app-product-list />

<!-- The route component renders no visible page content here. -->
<router-outlet />
Enter fullscreen mode Exit fullscreen mode

And the link becomes remarkably simple:

<a [routerLink]="[product.id, 'edit']">Edit</a>
Enter fullscreen mode Exit fullscreen mode

A route component as the dialog opener

Angular route configuration activates components; it cannot call a random “open modal” function on its own. The solution is a minimal route component that serves as the middleman:

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 });
        }
      });
  }
}
Enter fullscreen mode Exit fullscreen mode

Watching paramMap is essential because Angular tends to recycle the route component when only a parameter shifts. Jumping straight from /products/42/edit to /products/43/edit won't trigger a new constructor call; the subscription handles closing the previous dialog and opening the new one.

The reference identity check then disregards the old dialog's delayed afterClosed() response. Otherwise, switching from product 42 to product 43 could inadvertently push the user back to /products.

The cleanup handler addresses a separate scenario. If the user hits Back or navigates away with the dialog still open, Angular destroys the route component, which then closes the overlay.

The boolean ensures that programmatic close doesn't return the user to /products after they have moved on. Without it, clicking a link to /orders could briefly land them there before a late afterClosed() callback "corrects" the path back to /products. That would be unpleasant.

Avoiding hard-coded parent paths when possible

The example navigates to /products for clarity. In a reusable feature, relative navigation or a specific return destination may be preferable.

Be cautious with history.back() as a universal close mechanism. It works gracefully when the modal originates from /products, but if someone lands directly on /products/42/edit, pressing Back could exit the entire app.

A deterministic parent URL is typically the safer bet. If preserving the exact background state (including filters) is important, encode that state in the URL or switch to the auxiliary-route pattern discussed next.

Where a child path fits well

This option works best when:

  • the modal is tied to a single, clearly defined parent page;
  • the URL reads naturally as a resource or operation;
  • guards and resolvers should target the modal specifically;
  • deep links such as /products/42/edit hold meaning for users.

The trade-offs to keep in mind:

  • the parent must stay active and expose a dedicated child outlet;
  • a small route component is required for MatDialog;
  • reusing the same modal across unrelated pages means repeating child routes or adopting a broader layout;
  • the URL specifies the child destination but not an arbitrary underlying page.

This approach frequently emerges as the most balanced choice for resource editors.

Option 3: Using a Secondary Route

Angular's router supports activating multiple routes concurrently. A shell component can define one outlet for the main page content and another named outlet designated for the modal:

<router-outlet />
<router-outlet name="modal" />
Enter fullscreen mode Exit fullscreen mode

This arrangement allows the URL to capture both states separately:

/products(modal:edit/42)
Enter fullscreen mode Exit fullscreen mode

The syntax takes some getting used to, yet the underlying concept is quite clear:

  • primary outlet route: products;
  • modal outlet route: edit/42.

Setting Up the Routes

Both routes ought to be defined as children of the shell component that hosts these 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',
      },
    ],
  },
];
Enter fullscreen mode Exit fullscreen mode

When generating a link from within ProductsPage, the current route corresponds to the primary child. The modal outlet, however, is attached to the parent shell. Building the UrlTree relative to that parent is the way to go:

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',
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode
<a [routerLink]="editModalUrl(product.id)">Edit</a>
Enter fullscreen mode Exit fullscreen mode

Dismissing a named outlet involves navigating with that outlet assigned the value null, once again relative to the shell:

this.router.navigate(
  [
    {
      outlets: {
        modal: null,
      },
    },
  ],
  {
    relativeTo: this.route.parent,
  },
);
Enter fullscreen mode Exit fullscreen mode

Our reactive EditProductDialogRoute approach carries over nicely, including the paramMap subscription: switching from /products(modal:edit/42) to /products(modal:edit/43) can still reuse the same route component instance. The sole distinction lies in what happens upon dialog dismissal: rather than 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,
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

Just like the child-path technique, the route component needs to close its MatDialogRef when destroyed and must steer clear of another navigation if the destruction was triggered by an existing navigation.

What Sets Auxiliary Routes Apart

Query parameters require us to manually translate a value into modal state.

A child path confines the modal to a particular parent route.

An auxiliary route lets the Router natively understand the page and modal as two concurrent active branches. This proves especially useful when the same modal should appear above different pages:

/products(modal:cart)
/account(modal:cart)
/search?q=headphones(modal:cart)
Enter fullscreen mode Exit fullscreen mode

The primary route may shift without impacting the modal route, provided that fits the application's requirements.

Furthermore, auxiliary routes offer a complete route lifecycle. They support:

  • route parameters;
  • guards;
  • resolvers;
  • lazy-loaded components;
  • route-specific providers;
  • independent activation and deactivation.

What It Costs

The resulting URL is far from discreet:

/products(modal:edit/42)
Enter fullscreen mode Exit fullscreen mode

Developers encountering auxiliary routes for the first time might find the configuration and navigation syntax perplexing. Relative navigation demands extra caution, especially when named outlets are nested within feature routes.

Adopt this pattern only when you truly have two independent routing regions, not simply because it seems like the most impressive alternative available.

Material Dialog or Plain HTML?

Up to this point, each routed component has launched a MatDialog. This arrangement functions well since Material handles the challenging modal internals: overlay positioning, backdrop, focus trapping, Escape handling, focus restoration, and ARIA roles.

However, the Router remains agnostic regarding how the modal is rendered.

Take the named outlet scenario: the routed component can serve directly as 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,
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

That illustration highlights the routing connection rather than a production-ready modal. Building a custom modal entails additional responsibilities:

  • move focus inside upon opening;
  • keep focus contained while open;
  • respond to Escape for closing when suitable;
  • return focus after closing;
  • block interaction with underlying content;
  • provide a meaningful accessible name;
  • manage scrolling appropriately.

The native HTML <dialog> element or Angular CDK's dialog and accessibility utilities can alleviate some of that burden. A visually convincing <div class="modal"> does not automatically qualify as an accessible modal.

Route inputs and signals: where do they fit?

Angular’s withComponentInputBinding() lets you wire route state straight into component inputs. For routed modals, this can shrink the component code considerably.

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
  ],
};
export class EditProductModal {
  readonly productId = input.required<string>();
}

For the child-path and auxiliary-route approaches, this is a natural fit since productId is a genuine route parameter.

Query parameters can also benefit from direct input binding, but if an imperative overlay like MatDialog needs to react to open and close, you still need coordination logic.

Signals change how you read route data—they don’t change where that data should live in the URL.

Weighing the options side by side

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

Here’s how I typically decide:

  1. If the modal is short-lived and shouldn’t survive a refresh, skip routing entirely.
  2. If it’s optional context for the current page, a query parameter is the starting point.
  3. If it’s a natural sub-location of a page, use a child path.
  4. If it needs to work alongside multiple unrelated primary routes, an auxiliary route is worth considering.

There’s no single “correct” Angular pattern. The URL should reflect the product behavior you’re aiming for.

Guidelines that hold for every approach

Regardless of the method you pick:

  • Let the URL be the single source of truth.
  • Test deep links pasted into the browser, not just in-app navigation.
  • Exercise Back and Forward while the modal is visible.
  • Close the overlay when its route becomes inactive.
  • Make sure afterClosed() doesn’t fight an ongoing navigation.
  • Put stable IDs in the URL and fetch the rest from a service, store, or resolver.
  • Be explicit about whether closing adds or replaces a history entry.
  • Don’t let working routing compromise modal accessibility.
  • If the route may be server-rendered, include an SSR-safe fallback or render the modal content declaratively.

That accessibility point is worth calling out again. A modal with a clean URL but broken keyboard focus is still a broken modal.

Closing thoughts

Routing a modal isn’t about calling open() from the Router. It’s about deciding whether the modal belongs in your app’s navigable state.

Once that decision is made, the rest follows naturally: pick a URL shape that tells the truth, let navigation drive the UI, and treat closing the modal as a navigation in its own right.