Skip to content

Polymer Routing — Client-Side Routing with Page.js and LitElement

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Polymer Routing. We cover key concepts, practical examples, and best practices to help you master this topic.

Client-side routing enables single-page applications by mapping URLs to component views without full page reloads using the History API.

What You'll Learn

  • page.js router setup
  • Route definitions and parameters
  • Hash-based vs history-based routing
  • Route guards and authentication
  • Lazy Loading route components

Why It Matters

Routing transforms a component library into a full application. Users navigate with browser back/forward, and the app maintains state.

Real-World Use

A multi-page dashboard with separate routes for /dashboard, /users, /settings, and /reports, each lazy-loading their component bundles.

Routing Architecture

flowchart TD
    A[Routing] --> B[page.js]
    A --> C[Routes]
    A --> D[Views]
    B --> E[History API]
    B --> F[Hash Fallback]
    C --> G[Parameters]
    C --> H[Guards]
    D --> I[Lazy Load]
    D --> J[Component]
    style A fill:#e6f3ff,stroke:#4a90d9,stroke-width:2px

Basic Router Setup

import { LitElement, html } from 'lit';
import page from 'page/page.mjs';

class AppRouter extends LitElement {
  static properties = { page: { type: String }, params: { type: Object } };

  constructor() {
    super();
    this.page = 'home';
    this.params = {};
    this._setupRoutes();
  }

  _setupRoutes() {
    page('/', () => { this.page = 'home'; });
    page('/about', () => { this.page = 'about'; });
    page('/contact', () => { this.page = 'contact'; });
    page('*', () => { this.page = '404'; });
    page();
  }

  render() {
    switch (this.page) {
      case 'home': return html`<h2>Home Page</h2><p>Welcome to our SPA.</p>`;
      case 'about': return html`<h2>About Us</h2><p>Learn about our team.</p>`;
      case 'contact': return html`<h2>Contact</h2><form>...</form>`;
      default: return html`<h2>404</h2><p>Page not found.</p>`;
    }
  }
}
customElements.define('app-router', AppRouter);
<nav><a href="/">Home</a><a href="/about">About</a><a href="/contact">Contact</a></nav>
<app-router></app-router>

Expected output: Clicking nav links changes route. page.js intercepts clicks and updates the component state.

Route Parameters

import { LitElement, html, css } from 'lit';
import page from 'page/page.mjs';

class UserRouter extends LitElement {
  static properties = { view: { type: String }, userId: { type: Number } };

  static styles = css`
    nav { display: flex; gap: 8px; margin-bottom: 16px; }
    a { cursor: pointer; color: #1a237e; text-decoration: none; }
    a.active { font-weight: bold; text-decoration: underline; }
  `;

  constructor() {
    super();
    this.view = 'list';
    this.userId = 0;
    this._setupRoutes();
  }

  _setupRoutes() {
    page('/users', () => { this.view = 'list'; });
    page('/users/:id', (ctx) => { this.view = 'detail'; this.userId = parseInt(ctx.params.id); });
    page('/users/:id/edit', (ctx) => { this.view = 'edit'; this.userId = parseInt(ctx.params.id); });
  }

  render() {
    switch (this.view) {
      case 'list': return html`
        <h2>Users</h2>
        ${[1, 2, 3].map(id => html`
          <a href="/users/${id}">User ${id}</a>
        `)}`;
      case 'detail': return html`<h2>User Detail</h2><p>User ID: ${this.userId}</p>`;
      case 'edit': return html`<h2>Edit User</h2><p>Editing user ${this.userId}</p>`;
    }
  }
}
customElements.define('user-router', UserRouter);

Expected output: /users/:id captures the id parameter. ctx.params passes route data. Multiple route patterns map to different views.

Route Guards

import { LitElement, html } from 'lit';
import page from 'page/page.mjs';

class ProtectedRouter extends LitElement {
  static properties = { view: { type: String }, isAuthenticated: { type: Boolean } };

  constructor() {
    super();
    this.view = 'public';
    this.isAuthenticated = false;
    this._setupRoutes();
  }

  _setupRoutes() {
    page('/', () => { this.view = 'public'; });
    page('/login', () => { this.view = 'login'; });
    page('/dashboard', (ctx, next) => {
      if (!this.isAuthenticated) {
        ctx.redirect = true;
        page.redirect('/login');
        return;
      }
      this.view = 'dashboard';
    });
    page('/settings', (ctx, next) => {
      if (!this.isAuthenticated) {
        page.redirect('/login');
        return;
      }
      this.view = 'settings';
    });
    page.exit('/settings', (ctx, next) => {
      if (this._hasUnsavedChanges) {
        if (!confirm('Discard changes?')) return;
      }
      next();
    });
  }

  _login() {
    this.isAuthenticated = true;
    page.redirect('/dashboard');
  }

  _logout() {
    this.isAuthenticated = false;
    page.redirect('/');
  }

  render() {
    if (this.view === 'login') return html`
      <button @click=${this._login}>Log In</button>`;
    return html`
      <button @click=${this._logout}>Log Out</button>
      <p>View: ${this.view}</p>
    `;
  }
}
customElements.define('protected-router', ProtectedRouter);

Expected output: Route guards check authentication before rendering. page.exit provides leave confirmation.

Lazy Loading Routes

import { LitElement, html } from 'lit';
import page from 'page/page.mjs';

class LazyRouter extends LitElement {
  static properties = { currentView: { type: Object }, loading: { type: Boolean } };

  constructor() {
    super();
    this.currentView = null;
    this.loading = false;
    this._views = new Map();
    this._setupRoutes();
  }

  _setupRoutes() {
    page('/', () => this._loadView('home'));
    page('/dashboard', () => this._loadView('dashboard'));
    page('/reports', () => this._loadView('reports'));
    page('/admin', () => this._loadView('admin'));
  }

  async _loadView(name) {
    this.loading = true;
    try {
      if (!this._views.has(name)) {
        const module = await this._importModule(name);
        this._views.set(name, module.default || module);
      }
      this.currentView = {
        component: this._views.get(name),
        name
      };
    } catch (err) {
      this.currentView = { component: null, name: 'error' };
    } finally {
      this.loading = false;
    }
  }

  async _importModule(name) {
    switch (name) {
      case 'dashboard': return import('./dashboard-page.js');
      case 'reports': return import('./reports-page.js');
      case 'admin': return import('./admin-page.js');
      default: return import('./home-page.js');
    }
  }

  render() {
    if (this.loading) return html`<div class="loading">Loading...</div>`;
    if (!this.currentView) return html`<p>Select a page</p>`;
    if (!this.currentView.component) return html`<p>Error loading page</p>`;
    return html`<${this.currentView.component}></${this.currentView.component}>`;
  }
}
customElements.define('lazy-router', LazyRouter);

Expected output: Dynamic import loads route components on demand. Loading indicator shows during async import.

import { LitElement, html, css } from 'lit';
import page from 'page/page.mjs';

class Navigation extends LitElement {
  static styles = css`
    nav { display: flex; gap: 4px; background: #1a237e; padding: 8px 16px; border-radius: 8px; }
    a { color: white; text-decoration: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; }
    a:hover { background: rgba(255,255,255,0.1); }
    a.active { background: rgba(255,255,255,0.2); font-weight: bold; }
  `;

  render() {
    return html`
      <nav>
        <a href="/">Home</a>
        <a href="/products">Products</a>
        <a href="/about">About</a>
        <a href="/contact">Contact</a>
      </nav>
    `;
  }
}
customElements.define('app-nav', Navigation);

class ProductsPage extends LitElement {
  static properties = { category: { type: String }, sort: { type: String } };

  constructor() {
    super();
    this.category = 'all';
    this.sort = 'name';
  }

  _updateFilter(category, sort) {
    page.redirect(`/products?category=${category}&sort=${sort}`);
  }

  render() {
    return html`
      <h2>Products</h2>
      <div>
        <button @click=${() => this._updateFilter('all', this.sort)}>All</button>
        <button @click=${() => this._updateFilter('electronics', this.sort)}>Electronics</button>
        <button @click=${() => this._updateFilter('books', this.sort)}>Books</button>
      </div>
      <p>Category: ${this.category}, Sort: ${this.sort}</p>
    `;
  }
}
customElements.define('products-page', ProductsPage);

Expected output: Navigation links use href for page.js interception. Query parameters filter products.

Common Mistakes

  1. Not calling page() after routes - page() initializes the router.

  2. Hardcoding URLs instead of page.show - Use page.show(path) for programmatic navigation.

  3. Forgetting to handle 404 - Always add a catch-all route ('*').

  4. Memory leaks from page context - Use page.exit to clean up.

  5. Not using history push for SPAs - page.js uses pushState by default.

Practice Questions

  1. How does page.js integrate with LitElement?
  2. How do you capture URL parameters with page.js?
  3. How do you implement authentication guards?
  4. How do you lazy load route components?
  5. How do you programmatically navigate to a route?

Challenge: Build a full SPA with: lazy-loaded route modules, nested routes (users/list, users/:id), route guards (auth check), breadcrumb component, page transitions, and URL query parameter Parsing for filters.

FAQ

Can I use other routers with LitElement?

Yes. LitElement works with any router: vaadin-router, @vaadin/router, react-router (with adapter), or custom history-based routing.

How do I handle query parameters?

Parse ctx.querystring or use URLSearchParams in the route handler.

Does page.js work with hash-based routing?

Yes. Use page.base('/basepath') or configure hash routing.

How do I handle scroll restoration?

Store scroll position in history state or use scrollRestoration API in route transitions.

Mini Project

Build a documentation site with: lazy-loaded doc sections, URL parameter for doc ID, search query parameter, sidebar navigation with active state, breadcrumbs, and 404 page.

What's Next

Routing connects views. Learn how Polymer Theming provides consistent visual design across the application.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro