nst 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));
}
}
Enter fullscreen mode Exit fullscreen mode
### [](#design-choices-worth-calling-out)Design choices worth calling out
- **`saveReturnUrl(url?)`** — Callers can pass an explicit URL (e.g. from a guard’s `state.url`) or omit it to use `Router.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.
- **`clearReturnUrl` inside `navigateToReturnUrl`** — 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)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);
})
);
}
}
Enter fullscreen mode Exit fullscreen mode
**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)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;
}
}
Enter fullscreen mode Exit fullscreen mode
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)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']);
}
}
Enter fullscreen mode Exit fullscreen mode
`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)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;
}
Enter fullscreen mode Exit fullscreen mode
Only call `clearReturnUrl` from explicit logout—not from the 401 handler (that path _needs_ the saved URL).
## [](#endtoend-flows)End-to-end flows
### [](#session-expires-midwork)Session expires mid-work
1. User on `/app/jira/sprint-analysis?board=12`.
2. API returns 401 → interceptor calls `saveReturnUrl()` (current router URL).
3. Cookies cleared → navigate to `/`.
4. User logs in → `navigateToReturnUrl()` → back to sprint analysis with query string intact.
### [](#deep-link-while-logged-out)Deep link while logged out
1. User opens `/app/teams/3/summary` without a session.
2. Guard saves `state.url`, sends to `login`.
3. After login → same team summary.
### [](#explicit-logout-then-login)Explicit logout then login
1. User logs out → `clearReturnUrl()`.
2. User logs in → no saved URL → normal admin/manager/dev home routes.
## [](#security-and-privacy-notes)Security and privacy notes
- Store **paths only**, never passwords, tokens, PII, or API keys.
- Use **`sessionStorage`**, not `localStorage`, 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)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 `/login` does not overwrite a previously saved return URL (exclude login API).
- \[ \] Saved `/login` or `/reset-password` is never used as a return target.
- \[ \] `sessionStorage` unavailable → login still succeeds with default routing.
## [](#takeaways)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)🚀 About the Author
Thanks for reading! I'm **Shalinee Singh**, currently leading [**SignalsAI**](https://orgsignals.com/). 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](https://www.linkedin.com/in/shalinee-singh/).