You know the drill: someone is deep in a dashboard, tweaking filters, comparing sprints—and the session dies. They log in again and land on a generic home screen. All context gone.
This post walks through a small route retention pattern for Angular SPAs: persist the last meaningful URL when auth fails, then send the user there after a successful login. Explicit logout is handled separately so you do not “restore” a page the user chose to leave.
What we are solving
| Scenario | Desired behavior |
|---|---|
| API returns 401 (expired token) | Remember current page → login → return |
| User hits a protected route while logged out | Remember intended URL → login → return |
| User clicks logout | Do not restore old page; use normal post-login routing |
Storage is sessionStorage, not cookies: tab-scoped, cleared when the tab closes, and separate from auth tokens.
Architecture at a glance
sequenceDiagram
participant User
participant App
participant RouteRetention
participant API
participant Login
User->>App: Working on /app/reports/sprint-42
App->>API: Request with expired token
API-->>App: 401 Unauthorized
App->>RouteRetention: saveReturnUrl()
App->>App: clear auth session
App->>User: Redirect to landing/login
User->>Login: Signs in successfully
Login->>RouteRetention: hasReturnUrl()?
RouteRetention-->>Login: yes → /app/reports/sprint-42
Login->>App: navigateByUrl(saved route)
Three touchpoints cooperate:
-
RouteRetentionService— save, read, clear, and navigate to the return URL. -
HttpInterceptor— on 401, save route before tearing down the session. -
AuthGuard— on blocked navigation, save the URL the user tried to open. - Login component — after success, prefer the saved URL over default role-based routing.
The route retention service
A single injectable owns the returnUrl key and exclusion rules.
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class RouteRetentionService {
private readonly RETURN_URL_KEY = 'returnUrl';
constructor(private router: Router) {}
saveReturnUrl(url?: string): void {
try {
const urlToSave = url ?? this.getCurrentUrl();
if (this.shouldExcludeUrl(urlToSave)) {
return;
}
sessionStorage.setItem(this.RETURN_URL_KEY, urlToSave);
} catch (error) {
console.error('Failed to save return URL:', error);
}
}
getReturnUrl(): string | null {
try {
return sessionStorage.getItem(this.RETURN_URL_KEY);
} catch (error) {
return null;
}
}
clearReturnUrl(): void {
sessionStorage.removeItem(this.RETURN_URL_KEY);
}
hasReturnUrl(): boolean {
const returnUrl = this.getReturnUrl();
return !!(returnUrl && !this.shouldExcludeUrl(returnUrl));
}
async navigateToReturnUrl(defaultRoute = '/'): Promise<boolean> {
const returnUrl = this.getReturnUrl();
if (returnUrl && !this.shouldExcludeUrl(returnUrl)) {
this.clearReturnUrl();
return this.router.navigateByUrl(returnUrl);
}
return this.router.navigateByUrl(defaultRoute);
}
private getCurrentUrl(): string {
return this.router.url;
}
private shouldExcludeUrl(url: string): boolean {
if (!url || url === '/') {
return true;
}
const excludedPatterns = [
'/login',
'/reset-password',
'/forgot-username',
'/change-password',
'/404',
'/login/disabled',
'/support',
'/onboarding',
];
return excludedPatterns.some((pattern) => url.startsWith(pattern));
}
}
Design choices worth calling out
-
saveReturnUrl(url?)— Callers can pass an explicit URL (e.g. from a guard’sstate.url) or omit it to useRouter.url(handy when a 401 fires while the user is still on the current screen). -
shouldExcludeUrl— Never stash auth flows, error pages, or onboarding. Avoids redirect loops and weird “return to login” behavior. -
clearReturnUrlinsidenavigateToReturnUrl— One-shot use: after a successful restore, the key is gone so a second login does not reuse a stale path. - Try/catch around storage — Private mode or disabled storage should not break login; you fall back to default navigation.
Saving the route when the session expires (401)
Centralize session expiry in an HTTP interceptor. On 401 (except login/username-check endpoints), save the route, clear auth state, then send the user to your public entry route.
import { Injectable } from '@angular/core';
import {
HttpInterceptor,
HttpRequest,
HttpHandler,
HttpErrorResponse,
} from '@angular/common/http';
import { catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
import { Router } from '@angular/router';
import { RouteRetentionService } from './services/route-retention.service';
import { AuthCookieService } from './services/auth-cookie.service';
@Injectable()
export class AppInterceptor implements HttpInterceptor {
constructor(
private router: Router,
private authCookies: AuthCookieService,
private routeRetention: RouteRetentionService
) {}
intercept(req: HttpRequest<unknown>, next: HttpHandler) {
return next.handle(req).pipe(
catchError((err: HttpErrorResponse) => {
const isLoginAttempt = req.url.includes('/auth/login');
const isUsernameCheck = req.url.includes('/auth/check-username');
if (err.status === 401 && !isLoginAttempt && !isUsernameCheck) {
// User was on a real app screen — remember it
this.routeRetention.saveReturnUrl();
this.authCookies.clearAuthCookies();
this.router.navigate(['/']);
if (err.error) {
err.error.message = 'Session expired. Please login again.';
}
}
return throwError(() => err);
})
);
}
}
Why saveReturnUrl() with no argument? At the moment the 401 is handled, Router.url still reflects the page the user was viewing. Navigation to / happens afterward.
Why not save tokens in sessionStorage? Only the path goes there. Tokens stay in httpOnly cookies or whatever strategy you already use—never mix “where to go” with “who you are.”
Saving the route when a guard blocks access
If someone bookmarks /app/teams/5/analysis and opens it cold while logged out, the interceptor never ran—you need the guard.
import { Injectable } from '@angular/core';
import {
CanActivate,
ActivatedRouteSnapshot,
RouterStateSnapshot,
Router,
} from '@angular/router';
import { AppService } from '../app.service';
import { RouteRetentionService } from '../common/services/route-retention.service';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private router: Router,
private appService: AppService,
private routeRetention: RouteRetentionService
) {}
canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
if (!this.appService.isLoggedIn()) {
this.routeRetention.saveReturnUrl(state.url);
this.router.navigate(['login']);
return false;
}
return true;
}
}
Here you pass state.url so query params and nested paths are preserved (e.g. /app/teams/5?tab=git).
Restoring the route after login
On successful login, check for a saved URL before your usual role-based defaults.
private async onLoginSuccess(user: UserSession): Promise<void> {
const sessionSet = this.appService.setSession(
user.accessToken,
user.expiresAt,
user
);
if (!sessionSet) {
return;
}
if (this.routeRetention.hasReturnUrl()) {
await this.routeRetention.navigateToReturnUrl('/');
return;
}
// No saved route — existing product logic
if (user.isAdmin) {
await this.router.navigate(['/admin']);
} else if (user.isManager) {
await this.router.navigate(['/app/overview']);
} else {
await this.router.navigate(['/app/dev/overview']);
}
}
hasReturnUrl() reuses the same exclusion rules so a polluted storage key cannot send users to /login.
Apply the same block on any alternate login entry (OAuth, SSO, magic link) so behavior stays consistent.
Clearing retention on intentional logout
When the user logs out on purpose, wipe the saved URL. Otherwise the next login would feel like “resume session” even though they meant to leave.
clearSession(): void {
this.authCookies.clearAuthCookies();
this.routeRetention.clearReturnUrl();
this.currentUser = null;
}
Only call clearReturnUrl from explicit logout—not from the 401 handler (that path needs the saved URL).
End-to-end flows
Session expires mid-work
- User on
/app/jira/sprint-analysis?board=12. - API returns 401 → interceptor calls
saveReturnUrl()(current router URL). - Cookies cleared → navigate to
/. - User logs in →
navigateToReturnUrl()→ back to sprint analysis with query string intact.
Deep link while logged out
- User opens
/app/teams/3/summarywithout a session. - Guard saves
state.url, sends tologin. - After login → same team summary.
Explicit logout then login
- User logs out →
clearReturnUrl(). - User logs in → no saved URL → normal admin/manager/dev home routes.
Security and privacy notes
- Store paths only, never passwords, tokens, PII, or API keys.
- Use
sessionStorage, notlocalStorage, if you want retention scoped to the tab/session. - Maintain an exclusion list for auth and support routes to prevent open redirects disguised as “return URLs.” For stricter apps, validate saved paths against an allowlist of route prefixes.
- Do not log full URLs in production if they might contain sensitive query parameters; strip or blocklist query keys if needed.
- This pattern complements—not replaces—server-side session invalidation and CSRF protections.
Testing checklist
- [ ] 401 on a nested app route → login → lands on same route + query params.
- [ ] Direct navigation to protected URL while logged out → login → intended URL.
- [ ] Logout → login → default home (no restore).
- [ ] 401 on
/logindoes not overwrite a previously saved return URL (exclude login API). - [ ] Saved
/loginor/reset-passwordis never used as a return target. - [ ]
sessionStorageunavailable → login still succeeds with default routing.
Takeaways
- One small service + three integration points (interceptor, guard, login) give a noticeably better UX after session expiry.
- Save early (before clearing auth), restore once (then clear the key), exclude auth and error routes.
- Treat logout differently from expiry so user intent stays clear.
If you wire this up in your own app, start with the service and the 401 interceptor—that covers the most common “I was working and got kicked out” case. Add the guard next for deep links, then hook login last.
Have you shipped something similar—localStorage, router state, or a returnUrl query param? What edge cases bit you? Share in the comments.
🚀 About the Author
Thanks for reading! I'm Shalinee Singh, currently leading SignalsAI. I love building smart, user-centric solutions and sharing patterns that make life easier for developers.
Let's stay connected! You can find me and follow my journey on LinkedIn.
Top comments (0)