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:
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:
declare module '@craft-ng/core' {
interface CraftRouterRoutesRegistry {
App: typeof appRoutes.META_PATHS;
}
}From here on, every navigation target is checked against that registry.
Navigating
Two ways, both checked against the registry above.
As a link, with the CraftRouterLink directive:
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:
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:
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:
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:
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
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:
{
"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:
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:
| Phase | What is on screen |
|---|---|
0 → stayMs | the previous page — most navigations resolve here |
stayMs → +blankMs | a blank surface: something is coming |
beyond, for pendingMinMs at least | the 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:
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:
{
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:
npx craft route add /tasks --create-component tasks/tasksIt 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.