Ctrl + K

Routing System

The Routing system of Pulsar.

Routing

Routing in Pulsar is declarative and component-based. Routes are defined with builder functions that create page components, and the router coordinates which component is active based on the current URL.

Pulsar routing uses a builder pattern where routes define how to create components. This gives you full control over component lifecycle and parameter passing.

Basic Router Setup

A router is created with RouterComponent, which takes a Router instance and an optional layout function. The router is passed to mountApp() to initialize the application.

import 'package:pulsar_web/pulsar.dart';

void main() {
  mountApp(
    RouterComponent(
      router: Router(
        routes: [
          Route(path: '/', builder: (_) => HomePage()),
          Route(path: '/about', builder: (_) => AboutPage()),
          Route(path: '/contact', builder: (_) => ContactPage()),
        ],
        notFound: () => NotFoundPage(),
      ),
      layout: (page) => MainLayout(page),
    ),
  );
}

The builder function is called when the route matches. It receives route parameters and returns a component instance. The layout function wraps each page in a persistent layout.

Route Builders

Each Route uses a builder function that creates the page component:

Route(
  path: '/users/:userId',
  builder: (params) => UserProfilePage(params['userId']),
)

Path parameters are defined with a colon prefix (:userId). The router extracts these values and passes them to the builder function.

Accessing Route Parameters

final class UserProfilePage extends Component {
  final String? userId;
  
  UserProfilePage(this.userId);

  @override
  Morphic render() {
    return Div().classes("user-profile")([
      H1()(['User Profile']),
      P()(['User ID: ${userId ?? "unknown"}']),
    ]);
  }
}

// In router definition:
Route(
  path: '/users/:userId',
  builder: (params) => UserProfilePage(params['userId']),
)

Parameters are passed through the constructor, making dependencies explicit and the component testable.

Layout Function

The layout function wraps each page component in a persistent layout structure:

final class MainLayout extends Component {
  final Component page;
  
  MainLayout(this.page);

  @override
  Morphic render() {
    return Div().classes("app-layout")([
      Navbar(),
      Main().classes("main-content")([page]),
      AppFooter(),
    ]);
  }
}

// In RouterComponent:
RouterComponent(
  router: router,
  layout: (page) => MainLayout(page),
)

The Navbar and AppFooter persist across all routes. The page component changes based on the current route.

Important: The layout receives a Component, not a Morphic. The layout component is responsible for wrapping the page in the appropriate Morphic structure (Div, Main, etc.).

Nested Routing with catchAll

For nested routing scenarios, use the catchAll flag. This tells the router that a route can handle paths beyond its defined pattern.

void main() {
  mountApp(
    RouterComponent(
      router: Router(
        routes: [
          Route(path: '/', builder: (_) => HomePage()),
          Route(
            path: '/docs',
            builder: (_) => DocsPage(),
            catchAll: true,  //  Handles /docs/*
          ),
          Route(path: '/about', builder: (_) => AboutPage()),
        ],
        notFound: () => NotFoundPage(),
      ),
    ),
  );
}

With catchAll: true, the DocsPage component will match:

  • /docs — Base route
  • /docs/introduction — Nested path
  • /docs/components/state — Deeply nested path

The DocsPage component uses its own RouterComponent to handle nested routes:

final class DocsPage extends Component {
  late final Router docsRouter = Router(
    routes: [
      Route(path: '/docs/introduction', builder: (_) => IntroSection()),
      Route(path: '/docs/components', builder: (_) => ComponentsSection()),
      Route(path: '/docs/state', builder: (_) => StateSection()),
    ],
    notFound: () => IntroSection(),
  );

  @override
  Morphic render() {
    return Main()([
      //  RouterComponent used as Morphic child
      RouterComponent(
        router: docsRouter,
        layout: (section) => DocsLayout(section),
      ),
    ]);
  }
}

final class DocsLayout extends Component {
  final Component section;
  
  DocsLayout(this.section);

  @override
  Morphic render() {
    return Div().classes("docs-page")([
      DocsSidebar(),
      Div().classes("docs-content")([section]),
    ]);
  }
}

Important: RouterComponent is a Component, so it can be used directly as a child of any Morphic element (Div, Main, Section, etc.). This makes nested routing natural and composable. Use RouterComponent for routing logic instead of if/else in render() to keep render() pure.

Preserving Component Identity

By default, route builders create new component instances each time the route matches. For stateful components that need to preserve their state across navigation, store them as fields and return the field in the builder.

Critical: Component Identity and State — When a route component needs to preserve state across navigation, it must be declared as a field on the parent component, not created in the builder. Builders that create new instances break identity and lose state.

final class App extends Component {
  //  CORRECT - Stateful components stored as fields
  final DashboardPage dashboard = DashboardPage();
  final SettingsPage settings = SettingsPage();

  late final Router router = Router(
    routes: [
      Route(path: '/', builder: (_) => HomePage()),
      Route(path: '/dashboard', builder: (_) => dashboard),  //  Returns field
      Route(path: '/settings', builder: (_) => settings),    //  Returns field
    ],
    notFound: () => NotFoundPage(),
  );

  @override
  Morphic render() {
    return Main()([
      RouterComponent(
        router: router,
        layout: (page) => AppLayout(page),
      ),
    ]);
  }
}

// DashboardPage with state
final class DashboardPage extends Component {
  int activeTab = 0;
  List<String> recentItems = [];
  
  // State is preserved when navigating away and back
  
  @override
  Morphic render() {
    return Div()([
      H1()(['Dashboard - Tab $activeTab']),
      // ...
    ]);
  }
}

//  WRONG - Creates new instance, loses state
Router(
  routes: [
    Route(path: '/dashboard', builder: (_) => DashboardPage()),  //  New instance
  ],
)

When the builder returns a field instead of creating a new instance, the same component persists across route changes, preserving its state.

For navigation, use standard A() elements with href attributes. Pulsar automatically intercepts link clicks and uses client-side routing:

final class Navbar extends Component {
  @override
  Morphic render() {
    return Nav().classes("navbar")([
      A().href('/')(['Home']),
      A().href('/about')(['About']),
      A().href('/contact')(['Contact']),
    ]);
  }
}

No special navigation function is needed — standard links work out of the box.

To style active links, check the current path:

final class NavLink extends Component {
  final String href;
  final String label;
  
  NavLink(this.href, this.label);
  
  bool get isActive => window.location.pathname == href;

  @override
  Morphic render() {
    return A()
      .href(href)
      .classes(isActive ? 'nav-link active' : 'nav-link')
      ([label]);
  }
}

// Usage:
Nav()([
  NavLink('/', 'Home'),
  NavLink('/about', 'About'),
  NavLink('/contact', 'Contact'),
])

404 and Not Found

Handle unknown routes with the notFound parameter:

Router(
  routes: [
    Route(path: '/', builder: (_) => HomePage()),
    Route(path: '/about', builder: (_) => AboutPage()),
  ],
  notFound: () => NotFoundPage(),
)

final class NotFoundPage extends Component {
  @override
  Morphic render() {
    return Div().classes("not-found")([
      H1()(['404 - Page Not Found']),
      P()(['The page you are looking for does not exist.']),
      A().href('/')(['Go Home']),
    ]);
  }
}

Query Parameters

Access query parameters using the browser's window.location API:

final class SearchPage extends Component {
  String get searchQuery {
    final uri = Uri.parse(window.location.href);
    return uri.queryParameters['q'] ?? '';
  }
  
  int get page {
    final uri = Uri.parse(window.location.href);
    return int.tryParse(uri.queryParameters['page'] ?? '1') ?? 1;
  }

  @override
  Morphic render() {
    return Div().classes("search-page")([
      H1()(['Search Results']),
      P()(['Query: $searchQuery']),
      P()(['Page: $page']),
    ]);
  }
}

// Links with query params:
A().href('/search?q=pulsar&page=1')(['Search for "pulsar"'])

Multiple Route Parameters

Route(
  path: '/projects/:projectId/tasks/:taskId',
  builder: (params) => TaskDetailPage(
    projectId: params['projectId'],
    taskId: params['taskId'],
  ),
)

final class TaskDetailPage extends Component {
  final String? projectId;
  final String? taskId;
  
  TaskDetailPage({this.projectId, this.taskId});

  @override
  Morphic render() {
    return Div().classes("task-detail")([
      H1()(['Task Details']),
      P()(['Project: $projectId']),
      P()(['Task: $taskId']),
    ]);
  }
}

Router Mental Model

Understanding how routing works in Pulsar:

  • Builders create components — Routes use builder functions that are called when the route matches.
  • New instances by default — Builders create new component instances unless they return stored fields.
  • Preserve identity with fields — Store stateful components as fields and return them in builders.
  • Layout wraps pages — The layout function provides persistent UI around each page.
  • Parameters flow through constructors — Route params are passed explicitly to components.
  • Links are standard HTML — Use A() elements with href, no special navigation API.
  • catchAll delegates routing — Nested pages use RouterComponent for their own sub-paths.
  • Pure render() — Use RouterComponent for routing, not if/else in render().

Best Practices

  • Use builder functions — Create components in route builders.
  • Store stateful components as fields — Preserve identity and state across navigation.
  • Pass params through constructors — Make dependencies explicit.
  • Use layout for persistent UI — Navbar, AppFooter, etc.
  • Use catchAll for nested routing — Delegate sub-routing with RouterComponent.
  • Standard links for navigation — A().href() just works.
  • Keep render() pure — Use RouterComponent instead of manual if/else routing.

Anti-Patterns

  • ❌ Creating stateful route components in builders (loses state)
  • ❌ Manual routing with if/else in render() (use RouterComponent)
  • ❌ Accessing params via global state
  • ❌ Using full page reloads
  • ❌ Manually manipulating browser history
  • ❌ Forgetting catchAll for nested routers

Example: Complete Routing App

import 'package:pulsar_web/pulsar.dart';

void main() {
  mountApp(
    RouterComponent(
      router: Router(
        routes: [
          Route(path: '/', builder: (_) => HomePage()),
          Route(path: '/about', builder: (_) => AboutPage()),
          Route(
            path: '/users/:userId',
            builder: (params) => UserPage(params['userId']),
          ),
          Route(
            path: '/docs',
            builder: (_) => DocsPage(),
            catchAll: true,
          ),
        ],
        notFound: () => NotFoundPage(),
      ),
      layout: (page) => AppLayout(page),
    ),
  );
}

// Layout component
final class AppLayout extends Component {
  final Component page;
  
  AppLayout(this.page);

  @override
  Morphic render() {
    return Div().classes("app")([
      Navbar(),
      Main().classes("content")([page]),
      AppFooter(),
    ]);
  }
}

// Nested routing example
final class DocsPage extends Component {
  late final Router docsRouter = Router(
    routes: [
      Route(path: '/docs/introduction', builder: (_) => IntroSection()),
      Route(path: '/docs/components', builder: (_) => ComponentsSection()),
      Route(path: '/docs/state', builder: (_) => StateSection()),
    ],
    notFound: () => IntroSection(),
  );

  @override
  Morphic render() {
    return Main()([
      RouterComponent(
        router: docsRouter,
        layout: (section) => DocsLayout(section),
      ),
    ]);
  }
}

final class DocsLayout extends Component {
  final Component section;
  
  DocsLayout(this.section);

  @override
  Morphic render() {
    return Div().classes("docs-page")([
      DocsSidebar(),
      Div().classes("docs-content")([section]),
    ]);
  }
}

// Navbar with active links
final class Navbar extends Component {
  bool isActive(String path) {
    return window.location.pathname == path;
  }

  @override
  Morphic render() {
    return Nav().classes("navbar")([
      A()
        .href('/')
        .classes(isActive('/') ? 'active' : '')
        (['Home']),
      A()
        .href('/about')
        .classes(isActive('/about') ? 'active' : '')
        (['About']),
      A()
        .href('/docs')
        .classes(isActive('/docs') ? 'active' : '')
        (['Docs']),
    ]);
  }
}

final class HomePage extends Component {
  @override
  Morphic render() {
    return Div()([
      H1()(['Welcome to Pulsar']),
      P()(['A Dart-first web framework']),
    ]);
  }
}

final class UserPage extends Component {
  final String? userId;
  
  UserPage(this.userId);

  @override
  Morphic render() {
    return Div()([
      H1()(['User ${userId ?? "unknown"}']),
      P()(['Viewing profile for user ${userId ?? "unknown"}']),
    ]);
  }
}

final class NotFoundPage extends Component {
  @override
  Morphic render() {
    return Div()([
      H1()(['404 - Not Found']),
      P()(['The page you are looking for does not exist.']),
      A().href('/')(['Go Home']),
    ]);
  }
}

final class AppFooter extends Component {
  @override
  Morphic render() {
    return Footer().classes("AppFooter")([
      P()(['© 2024 Pulsar Framework']),
    ]);
  }
}

final class DocsSidebar extends Component {
  @override
  Morphic render() {
    return Aside().classes("docs-sidebar")([
      Nav()([
        A().href('/docs/introduction')(['Introduction']),
        A().href('/docs/components')(['Components']),
        A().href('/docs/state')(['State']),
      ]),
    ]);
  }
}

Core principle: Routes use builder functions to create components with explicit parameters. Stateful components must be stored as fields to preserve identity. The layout function provides persistent UI. Navigation uses standard HTML links. Nested routing uses RouterComponent to keep render() pure.