Skip to content

9. Wire up routing

Goal: put the tasks page behind a route, and make a missing provider a compile error instead of a blank screen.

The headline is this: navigation only accepts routes that exist. Not a string you hope is right — a value checked against the paths your app actually declares. A typo, a removed route, a missing param: all compile errors, at the call site.

This is where the dev tooling earns its keep.

Declare the route

A Craft component is mounted with loadCraftComponent(...), spread into the route:

typescript
import { loadCraftComponent } from '@craft-ng/component';
import { craftRoutes } from '@craft-ng/core';

export const { appRoutes } = craftRoutes('app', [
  {
    path: 'tasks',
    ...loadCraftComponent(({ withRetry }) =>
      withRetry(import('./tasks/tasks')).then(
        ({ default: component }) => component,
      ),
    ),
  },
]);

withRetry wraps the dynamic import, so a chunk that fails to download is retried instead of dead-ending the navigation. Keep the import specifier literal — a computed one can't be statically discovered by the bundler.

Register the paths

Declaring the collection's paths is what makes navigation type-safe across the app:

typescript
declare module '@craft-ng/core' {
  interface CraftRouterRoutesRegistry {
    App: typeof appRoutes.META_PATHS;
  }
}

From here on, every navigation target is checked against that registry.

Two ways, both checked against the registry above.

As a link, with the CraftRouterLink directive:

typescript
import { a } from '@craft-ng/component';
import { CraftRouterLink } from '@craft-ng/core';

// in a template
a({ craftRouterLink: { to: 'tasks' } }, 'Tasks').pipe(CraftRouterLink);

Imperatively, by yielding the router:

typescript
function* () {
  const router = yield* CraftRouter(undefined, ({ navigate }) => ({ navigate }));

  const goToTask = craftMethod('goToTask', function* (taskId: string) {
    void router.navigate({ to: 'tasks/:taskId', params: { taskId } });
  });

  return { goToTask };
}

The target is { to, params?, queryParams? }, and all of it is checked:

typescript
router.navigate({ to: 'taks' }); // ✗ not a known path
router.navigate({ to: 'tasks/:taskId' }); // ✗ params.taskId is missing
router.navigate({ to: 'tasks/:taskId', params: { id: '1' } }); // ✗ wrong param
router.navigate({ to: 'tasks/:taskId', params: { taskId: '1' } }); // ✓

Note that navigate comes from yielding CraftRouter, not from injecting it — so the dependency is tracked and the route check can see it.

The check that pays for all of this

Each route component gets its own check: RouteCheckedDI compares what the component needs against what is actually available at that path, and CanRun turns a mismatch into a TypeScript error.

The tasks route created above remains visible as the source of truth; the check below validates that route's component and its path: 'tasks' context. An AI can also create this Craft NG routing boilerplate very well — including the lazy import, retry handling, route registry and DI check — from the component and path you provide.

Declare one local alias for your app's context, then one CanRun per route:

typescript
import type { ActivatedRoute, Router } from '@angular/router';
import type { CanRun, ComponentDepsOf, RouteCheckedDI } from '@craft-ng/core';

type AppRouteCheckedDI<
  Component,
  RouteInputs extends string = never,
  Context extends string = 'app route component',
> = RouteCheckedDI<
  ComponentDepsOf<Component>,
  'CraftRouter',
  Router | ActivatedRoute,
  Context,
  RouteInputs
>;

type _CanRunTasks = CanRun<
  AppRouteCheckedDI<
    (typeof import('./tasks/tasks'))['default'],
    never,
    'path: "tasks"'
  >
>;

The alias fixes the ambient context once — what the app provides by name ('CraftRouter') and by value (Router | ActivatedRoute). Each route then supplies three things: the component, the route inputs it may bind (a path param like 'taskId', or never), and a label used in error messages.

A mismatch reads like this:

The TaskList service is not provided in path: "tasks"
Input "taskId" is not provided in path: "tasks"

Remember step 3, where toProvide was flagged as failing only at runtime? This is what closes that hole.

Why one check per route

RouteCheckedDI validates a single component with no recursion between routes, so the cost is flat: a file with two hundred routes costs two hundred independent checks and never hits TypeScript's instantiation ceiling. See Scaling routes.

Prove the exceptions are handled

Guards, matchers and resolvers can raise a craftException — and so can a component's own factory or providers, whose unhandled codes flow up into the route. One call asserts that every reachable code has a handler, and that no handler exists for a code nothing produces:

typescript
assertExhaustiveRouteExceptions(appRoutes);

The ESLint rule craft-ng/require-assert-exhaustive-route-exceptions adds it for you.

A component can also handle its own codes with .pipe(catchTag.exhaustive(...)), which removes them from the route's union — see An unhandled exception doesn't just disappear. Everything else is on Route exception handling.

Wire it into the app

typescript
import { craftAppConfig } from '@craft-ng/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';

export const appConfig = craftAppConfig({
  routingDeps: appRoutes.META_DATA,
  providers: [provideRouter(appRoutes.toRoutes(), withComponentInputBinding())],
});

toRoutes() hands Angular the real routes; META_DATA hands the compile-time graph to craftAppConfig.

Validate the routing safety net

After wiring the routes into craftAppConfig, add the verification script:

json
{
  "scripts": {
    "craft:verify-routes": "craft route verify --project tsconfig.app.json"
  }
}

Then run npm run craft:verify-routes. The command first audits the routes you just created: commenting or forgetting a CanRun/RouteCheckedDI proof is reported even though an unused type alias would otherwise compile. It also checks existing exception, pending-component and lazy-retry bookkeeping. Temporary valid and invalid examples then confirm that the compiler catches missing DI providers, template dependencies, route inputs, pending/error component contracts and unhandled route exceptions. Fixtures are deleted when the command ends; use --json for CI or --keep-fixtures to inspect a failure.

What the user sees while a route loads

A guard or a resolver that does real work leaves the app frozen on the previous page. Swap provideRouter for provideCraftRouter and render CraftRouterOutlet() instead of <router-outlet>, and the URL commits immediately while the chain runs behind it:

typescript
import { CraftRouterOutlet } from '@craft-ng/component';
import { provideCraftRouter, withTransitionTimings } from '@craft-ng/core';

export const appConfig = craftAppConfig({
  routingDeps: appRoutes.META_DATA,
  providers: [
    provideCraftRouter(
      appRoutes.toRoutes(),
      withComponentInputBinding(),
      withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),
    ),
  ],
});

Those three numbers are the whole waiting story, and they exist so a fast navigation shows nothing at all:

PhaseWhat is on screen
0 → stayMsthe previous page — most navigations resolve here
stayMs → +blankMsa blank surface: something is coming
beyond, for pendingMinMs at leastthe pending component (a spinner, a skeleton)

pendingMinMs is the anti-flicker floor: once the loader appears it stays put, so it can't flash for 40ms.

Changing the pending component

The default spinner is replaceable globally:

typescript
provideCraftRouter(
  appRoutes.toRoutes(),
  withPendingComponent(MyBrandedSpinner),
);

…or per route, which is where it gets interesting — a skeleton shaped like the page it is standing in for reads far better than a spinner:

typescript
{
  path: 'tasks',
  ...loadCraftComponent(/* … */),
  pendingComponent: () => import('./tasks/tasks-skeleton'),
  stayMs: 150, // this route is slower: get to the skeleton sooner
  blankMs: 0,  // and skip the blank phase entirely
}

Route-level values override the global ones, so you tune only the routes that need it.

See it running

The slow-page demo exists for exactly this: two deliberately slow steps (~1.5s each) so you can watch the stay → blank → loader phases play out. The first visit is slow, a revisit is instant thanks to the query cache, and a "clear cache" button replays it. Source: slow-page.routes.ts.

Full details — the phase diagram, per-route overrides, view transitions and the DI check on skeletons — are on Non-blocking navigation.

Let the CLI write it

Hand-writing these pieces gets old. The CLI does it for you and the output stays ordinary, editable TypeScript:

shell
npx craft route add /tasks --create-component tasks/tasks

It picks the right collection, creates a lazy routes file per feature, adds the loader, the check block and the registry entry, then runs ESLint and tsc. Use --dry-run first.

What you gained

Routing where a forgotten provider, a misspelled input, an unhandled exception or a route pointing at nothing stops the build instead of reaching production.

The parts you'll want later

Route-scoped providers, guards as bare generators and centralised exception handling all live under Routing. Splitting a growing collection across lazy child files is Scaling routes.