Advanced Routing — Lazy Loading, Preloading Strategies, and Route Animations

The fundamentals of routing handle the basics of mapping URLs to components. Advanced routing techniques take that further: preloading strategies that download lazy-loaded chunks in the background after the initial page load (reducing the perceived load time for secondary pages), nested router outlets for complex layouts with persistent navigation sidebars, and route animations that add polished page transitions. Mastering these makes the difference between an Angular application that feels like a web app and one that feels like a native application.

Preloading Strategies

Strategy Behaviour Use For
NoPreloading (default) Load lazy modules only when navigated to Low bandwidth environments, large apps
PreloadAllModules Preload all lazy modules after initial load Apps where all sections are likely visited
Custom: QuicklinkStrategy Preload modules for links visible in viewport Optimal — preloads what user will likely visit next
Custom: data: { preload: true } Preload only routes marked with a flag Manual control — preload critical paths only
Custom: NetworkAwareStrategy Preload only on fast connections (4G/WiFi) Mobile-first apps with data-conscious users

Route Animation States

State When Typical Use
void Element does not exist in the DOM Before entering or after leaving
* (wildcard) Any state Match any transition
Custom name ('in') Named state defined by trigger Specific visual states
:enter Alias for void => * Element entering the DOM
:leave Alias for * => void Element leaving the DOM
Note: Preloading strategies run after the initial page has loaded and the application is idle. They download lazy-loaded chunk files in the background so when the user navigates to those routes, the code is already cached — the route activates instantly. PreloadAllModules is simple but preloads everything regardless of relevance. A custom strategy with data: { preload: true } on selected routes gives you fine-grained control — preload the dashboard and tasks pages (the most likely next destinations), but not the admin panel (rarely visited).
Tip: For route animations, attach the animation data to a @routeAnimation trigger on the <router-outlet>‘s container element. Read the animation state from the currently activated route’s data.animation property using outlet.activatedRouteData?.['animation']. Define different states for different pages (like 'tasks', 'detail') so you can have directional slide animations — sliding right when going from list to detail, left when going back.
Warning: Route animations require BrowserAnimationsModule (or provideAnimations() in standalone bootstrap). Without it, Angular silently ignores animation triggers and no errors appear — animations simply do not run. Also remember that animations that use position: absolute to overlap entering and leaving routes require a parent container with position: relative and defined dimensions — without this, the entering and leaving components overlap in unexpected ways during the transition.

Complete Advanced Routing Examples

// ── Custom preloading strategy ────────────────────────────────────────────
// core/strategies/selective-preload.strategy.ts
import { Injectable }       from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of, timer }     from 'rxjs';
import { switchMap }         from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class SelectivePreloadingStrategy implements PreloadingStrategy {
    preload(route: Route, load: () => Observable<any>): Observable<any> {
        // Only preload routes marked with data: { preload: true }
        if (route.data?.['preload'] !== true) return of(null);

        // Delay preload by 2 seconds — don't compete with initial load
        return timer(2000).pipe(switchMap(() => load()));
    }
}

// Network-aware preloading strategy
@Injectable({ providedIn: 'root' })
export class NetworkAwarePreloadingStrategy implements PreloadingStrategy {
    preload(route: Route, load: () => Observable<any>): Observable<any> {
        if (route.data?.['preload'] !== true) return of(null);

        // Check connection type via Navigator API
        const connection = (navigator as any).connection;
        const effectiveType = connection?.effectiveType ?? '4g';

        // Only preload on fast connections
        if (['slow-2g', '2g', '3g'].includes(effectiveType)) return of(null);

        return load();
    }
}

// app.config.ts — configure preloading
import { provideRouter, withPreloading } from '@angular/router';

export const appConfig = {
    providers: [
        provideRouter(
            routes,
            withPreloading(SelectivePreloadingStrategy),
        ),
    ],
};

// Routes — mark which should be preloaded
export const routes: Routes = [
    { path: 'tasks', data: { preload: true, animation: 'tasks' },     loadChildren: ... },
    { path: 'admin', data: { preload: false, animation: 'admin' },    loadChildren: ... },
    { path: 'help',  data: { preload: true, animation: 'help' },      loadChildren: ... },
];

// ── Nested layouts with named router outlets ──────────────────────────────

// Persistent sidebar layout
// app.routes.ts
export const routes: Routes = [
    {
        path: 'tasks',
        canActivate: [authGuard],
        component: TasksLayoutComponent,    // layout component with sidebar
        children: [
            { path: '', component: TaskListComponent },
            { path: 'new', component: TaskFormComponent },
            { path: ':id', component: TaskDetailComponent },
        ],
    },
];

// tasks-layout.component.ts — persistent sidebar + dynamic content area
@Component({
    selector: 'app-tasks-layout',
    standalone: true,
    imports: [RouterOutlet, CommonModule, TaskSidebarComponent],
    template: `
        <div class="tasks-layout">
            <aside class="tasks-layout__sidebar">
                <app-task-sidebar [stats]="store.stats()"></app-task-sidebar>
            </aside>
            <main class="tasks-layout__content">
                <router-outlet></router-outlet>   <!-- child routes render here -->
            </main>
        </div>
    `,
})
export class TasksLayoutComponent implements OnInit {
    store = inject(TaskStore);
    ngOnInit(): void { this.store.loadAll(); }
}

// ── Route animations ──────────────────────────────────────────────────────
// shared/animations/route.animations.ts
import {
    trigger, transition, style, animate, query, group,
    animateChild,
} from '@angular/animations';

export const routeAnimations = trigger('routeAnimations', [
    // Fade animation for all transitions
    transition('* <=> *', [
        query(':enter, :leave', [
            style({ position: 'absolute', width: '100%' }),
        ], { optional: true }),
        group([
            query(':leave', [
                animate('200ms ease-out', style({ opacity: 0, transform: 'translateX(-20px)' })),
            ], { optional: true }),
            query(':enter', [
                style({ opacity: 0, transform: 'translateX(20px)' }),
                animate('200ms 100ms ease-in', style({ opacity: 1, transform: 'translateX(0)' })),
            ], { optional: true }),
        ]),
    ]),
]);

// Slide animation — direction based on route order
export const slideAnimation = trigger('slideAnimation', [
    transition('tasks => detail', [
        style({ position: 'relative' }),
        query(':enter, :leave', [style({ position: 'absolute', top: 0, left: 0, width: '100%' })]),
        query(':enter', [style({ left: '100%' })]),
        query(':leave', animateChild()),
        group([
            query(':leave', [animate('300ms ease-out', style({ left: '-100%' }))]),
            query(':enter', [animate('300ms ease-out', style({ left: '0%' }))]),
        ]),
        query(':enter', animateChild()),
    ]),
    transition('detail => tasks', [
        style({ position: 'relative' }),
        query(':enter, :leave', [style({ position: 'absolute', top: 0, left: 0, width: '100%' })]),
        query(':enter', [style({ left: '-100%' })]),
        query(':leave', animateChild()),
        group([
            query(':leave', [animate('300ms ease-out', style({ left: '100%' }))]),
            query(':enter', [animate('300ms ease-out', style({ left: '0%' }))]),
        ]),
        query(':enter', animateChild()),
    ]),
]);

// app.component.ts — bind animation trigger
@Component({
    selector:   'app-root',
    standalone: true,
    imports:    [RouterOutlet],
    animations: [routeAnimations],
    template: `
        <div [@routeAnimations]="getRouteAnimationData(outlet)"
             style="position: relative; overflow: hidden;">
            <router-outlet #outlet="outlet"></router-outlet>
        </div>
    `,
})
export class AppComponent {
    getRouteAnimationData(outlet: RouterOutlet): string | undefined {
        return outlet?.activatedRouteData?.['animation'];
    }
}

// app.config.ts — enable animations
import { provideAnimations } from '@angular/platform-browser/animations';

export const appConfig = {
    providers: [
        provideAnimations(),   // required for Angular animations to work
        provideRouter(routes, withPreloading(SelectivePreloadingStrategy)),
    ],
};

How It Works

Step 1 — Preloading Strategies Hook Into the Router’s Idle Time

After the initial navigation completes and the application is idle, the Router calls preload(route, loadFn) for every lazy-loaded route in the configuration. Your strategy function decides whether to call loadFn() (which triggers the download and module registration) or return of(null) (skip). The downloaded module is cached — when the user actually navigates to that route, Angular uses the cached version and activates instantly.

Step 2 — Nested Layouts Use Child Router Outlets

A layout component that contains a <router-outlet> acts as the host for its child routes. The layout’s own DOM (sidebar, header, breadcrumbs) persists across child route changes — only the content inside the outlet changes. This is the correct architecture for pages with persistent navigation panels: the layout component is activated once by the parent route and stays alive while any child route is active. Child components never re-render the layout.

Step 3 — Route Animation Data Drives Transitions

Adding data: { animation: 'tasks' } to a route lets the root component read the current route’s animation state with outlet.activatedRouteData?.['animation']. Binding this to [@routeAnimations]="state" on the outlet’s container element triggers the animation whenever the state changes (navigation). Different state values can trigger different animation styles — slide right going into detail, slide left going back to list.

Step 4 — group() and query() Coordinate Multi-Element Animations

When navigating, both the leaving component and entering component exist simultaneously during the transition. query(':enter') selects the entering component’s host element; query(':leave') selects the leaving one. group([]) runs multiple animations in parallel — slide the leaving element out while simultaneously sliding the entering element in. Without group(), the leave animation would complete before the enter animation starts.

Step 5 — withInMemoryScrolling Restores Scroll Position

By default, Angular does not manage scroll position on navigation — browsers maintain their own position. withInMemoryScrolling({ scrollPositionRestoration: 'top' }) scrolls to the top of the page on every navigation. 'enabled' restores the previous scroll position when navigating back. anchorScrolling: 'enabled' supports navigating to named anchors (/tasks#overdue). These options provide the scroll behaviour users expect from traditional multi-page applications.

Common Mistakes

Mistake 1 — Animations not working — missing provideAnimations()

❌ Wrong — animations silently fail without the provider:

// app.config.ts
export const appConfig = {
    providers: [
        provideRouter(routes),   // no provideAnimations()!
        // Animation triggers in templates do nothing — no error shown
    ],
};

✅ Correct — always include provideAnimations():

import { provideAnimations } from '@angular/platform-browser/animations';
providers: [ provideAnimations(), provideRouter(routes) ]

Mistake 2 — Preloading all modules regardless of relevance

❌ Wrong — PreloadAllModules downloads admin, reports, and settings on every page load:

provideRouter(routes, withPreloading(PreloadAllModules))
// Downloads admin panel code for all users, even those who will never see it

✅ Correct — selective preloading for likely-visited routes only:

provideRouter(routes, withPreloading(SelectivePreloadingStrategy))
// Only preloads routes with data: { preload: true }

Mistake 3 — Overlapping route animation elements without position: absolute

❌ Wrong — entering and leaving elements stack vertically during transition:

transition('* <=> *', [
    query(':enter, :leave', style({ width: '100%' })),   // no position: absolute!
    // Elements stack — layout jumps during animation
])

✅ Correct — use absolute positioning for overlap:

transition('* <=> *', [
    query(':enter, :leave', style({ position: 'absolute', width: '100%' })),
    // Elements overlap cleanly during animation
])

Quick Reference

Task Code
Preload all provideRouter(routes, withPreloading(PreloadAllModules))
Selective preload Custom strategy + data: { preload: true } on routes
Scroll to top withInMemoryScrolling({ scrollPositionRestoration: 'top' })
Enable animations provideAnimations() in app.config.ts
Route animation data data: { animation: 'stateName' } on route
Read animation state outlet.activatedRouteData?.['animation']
Animation trigger [@myTrigger]="animationState" on outlet container
Nested layout Parent route component with <router-outlet> + children array
Parallel animations group([query(':enter', [...]), query(':leave', [...])])

🧠 Test Yourself

A SelectivePreloadingStrategy checks route.data?.['preload']. The tasks route has data: { preload: true } and the admin route has no data property. When are these routes’ code bundles downloaded?