angularauthenticationangular-routingauth-guardangular-ssr

Angular 17: login page flashing in every page refresh/route to other page on SSR enabled


After successful login, user navigate to home page. but when page is refreshed, login page flashes for a second. Most probably the reason is, same page is rendering on server-side 1st, then on Client-side. I've set it as, if user authentication false, redirect to login page, login then redirect to respected page on authGuard.

authGuard.ts

export const AuthGuard: CanActivateFn | CanActivateChildFn = (route, state) => {
  const router: Router = inject(Router);
  return inject(AuthService).checkAuthentication()
  .pipe(
    switchMap((isAuthenticated)=>{
      if (!isAuthenticated) {
        return router.createUrlTree(['auth/sign-in'], {queryParams: {param: state.url}})
      }
      return of(true);
    })
  );
}

While refreshing page, and when page is rendering on server side it gets Authentication false that's why it renders login page then send it to clint-side. But on client side it get's Authentication true then it's display home page(or other page). however between this time route doesn't change. here is the screen shots where authentication false on server & true on client side at the same time. SSR and CSR Compare Screenshots

However, if i turn off SSR, login page does not flash at all. So, is there any way to solve this flashing issue without turned of SSR mode?

app.route.ts

export const routes : Routes = [
  {
    path: '',
    component: LayoutComponent,
    canActivate: [AuthGuard],
    canActivateChild: [AuthGuard],
    children: [
      { path: 'home', component: DashboardComponent },
      { path: 'settings', loadChildren: () => import('./modules/settings/settings.routes') },
      { path: 'users', loadChildren: () => import('./modules/users/users.routes')},
    ]
  },
  {
    path: 'auth',
    loadChildren: ()=> import('./login/login.routes')
  },
  { path: '', pathMatch: 'full', redirectTo: 'home' },
  { path: '**', pathMatch: 'full', redirectTo: 'home' },
];

auth.service.ts

@Injectable({ providedIn: 'root' })
export class AuthService {
 private _storeService = inject(StoreService);

 signIn(credentials: { username: string; password: string }): Observable<any> {

        return this._httpClient.post(AUTH_API + 'User/login', credentials).pipe(
            switchMap((response: any) => {
                this._storeService.saveAccessToken(response.token); // save token on sessionStorage
                this._storeService.saveActiveUser(response.user);
                return of(response);
            }),
        );
    }

 get accessToken(): string {
        return this._storeService.getAccessToken() ?? '';
    }

  checkAuthentication : Observable<boolean> (){

    if ( !this.accessToken ||  AuthUtils.isTokenExpired(this.accessToken))
        {
          return of(false);
        } else { return of(true) }
   }

}

Please let me know if i need to share more code ... thanks.

I've tried going through angular documents / stackoverflow / googleing relating this issue. but couldn't find suitable solution for this problem. also,i could not find that much example with functional approach.


Solution

  • Explanation:

    The problem you're experiencing arises from the nature of Server-Side Rendering (SSR) in Angular. When the server renders the page, it doesn't have access to client-specific data like authentication tokens stored in the browser (e.g., in localStorage or sessionStorage). So the server assumes the user is not authenticated and renders the login page. However, once the client-side JavaScript kicks in, it identifies the user as authenticated (thanks to the token) and quickly redirects to the target page. This results in the brief flash of the login page you're observing.


    Possible solution:

    You could consider implementing a shared authentication state between the server and client. This would involve adding an interceptor to send the authentication token to the server within the initial request (via a cookie). You'll need to adjust your authentication flow to verify the token on the server side and initialize the application with the user already authenticated.


    Implementation: (Angular 17 and nodeJS, you can use anything in your backend, just make sure to handle the cookie)

    1. Step 1: Create an interceptor
    import { HttpInterceptorFn } from '@angular/common/http'
    import { inject, PLATFORM_ID } from '@angular/core'
    import { isPlatformServer } from '@angular/common'
    import { REQUEST } from '../../express.tokens' // I will explain this later
    export const authInterceptor: HttpInterceptorFn = (req, next) => {
        const platformId = inject(PLATFORM_ID)
        const request = inject(REQUEST, { optional: true })
        if (isPlatformServer(platformId) && request && request.cookies) {
            req = req.clone({
                headers: req.headers.set('myCookie', request.cookies),
            })
        } else {
            req = req.clone({ withCredentials: true })
        }
        return next(req)
    }
    
    1. Step 2: Provide the interceptor Inside the providers array in your app.config.ts, add:
    provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
    
    1. Step 3: Provide the REQUEST in your server

    Import the REQUEST:

    import { REQUEST } from './src/express.tokens'  // I will explain this later
    

    Update server.get() to :

    server.get('*', (req, res, next) => {
            const { protocol, originalUrl, baseUrl, headers } = req
            req.cookies = req.headers.cookie // Add this
            commonEngine
                .render({
                    bootstrap,
                    documentFilePath: indexHtml,
                    url: `${protocol}://${headers.host}${originalUrl}`,
                    publicPath: browserDistFolder,
                    providers: [
                        { provide: APP_BASE_HREF, useValue: baseUrl },
                        { provide: REQUEST, useValue: req }, // Add this
                    ],
                })
                .then((html) => res.send(html))
                .catch((err) => next(err))
        })
    
    1. Step 4: Update your Express server in the backend

    Add this right after the initialisation of the express server:

    // Middleware to handle 'myCookie' (Load data in SSR)
    app.use((req, res, next) => {
        const myCookie = req.headers['mycookie']
        if (myCookie) {
            if (typeof myCookie === 'string') {
                req.headers.cookie = myCookie
            } else {
                req.headers.cookie = myCookie[0]
            }
        }
        next()
    })
    

    Limitations and considerations:

    As you may already know, starting Angular version 17, Universal has been moved into the Angular CLI repo. Code has been refactored and renamed (mostly under @angular/ssr now). With this migration, the express tokens were lost so now we have to define them manually. You need to create a new file express.tokens.ts:

    import { InjectionToken } from '@angular/core'
    import { Request, Response } from 'express'
    
    export const REQUEST = new InjectionToken<Request>('REQUEST')
    export const RESPONSE = new InjectionToken<Response>('RESPONSE')
    

    This would lead you to what I consider a huge regression from Angular v16 to Angular v17. Now, ng serve does allow SSR, but it does not use the server.ts, Instead it has it's own middlewares and server instance. This would lead to REQUEST being always null when running ng serve. This is not documented anywhere btw, but you can check this issue to stay up to date with the latest updates.

    Please note that for the REQUEST injection to work in the build, you need to set prerender to false in your angular.json.


    Conclusion:

    This solution will only work in production builds with prerender set to false. I also need to mention that this is only an Angular v17 problem, it works like a charm on all the other versions.

    I will update this answer as soon as a workaround is found or the Angular team provides an update.

    March 1st update: The Angular team is intending to solve the issue this May (v18).

    This is a known problem and we're working on resolving it. Currently, the plan is to ship it as part of the v18 release this May. If anything unexpected pops up and we're unable to deliver in this timeframe we'll follow up here. Source