Skip to main content

Server-side rendering

The library is SSR-safe: it no-ops on the server rather than throwing during render or prerender. What it does not do is authenticate anyone there.

What happens on the server​

PieceServer behaviour
withKeycloak()No keycloak-js instance is constructed; KEYCLOAK_INSTANCE is null
Adapter init()Returns immediately, leaving the user unauthenticated
AuthService.ready()Becomes true — initialisation settled, with no session
authenticated()false
authGuardReturns false — refuses without redirecting
authTokenInterceptorSends requests bare (nobody is authenticated)
login() / logout()Throw — there is no browser to redirect

Why the guard fails closed​

Two reasons, and both matter.

There is no session to evaluate. Tokens are held in memory in the browser and never written to a cookie the server could read. The server genuinely does not know who the user is, so any answer other than "refuse" would be a guess.

A rendered response can be cached. If the guard optimistically allowed activation, the server would render a protected page's HTML. Anything in front of your application that caches responses — a CDN, a reverse proxy, Angular's own prerendering — could then serve one user's protected markup to another. Refusing means protected routes render nothing server-side and resolve on the client.

The visible consequence: a protected route delivers no server-rendered content, so the user sees your app shell until the client takes over.

Structuring a hybrid application​

Put your public, SEO-relevant content on unguarded routes, and everything behind authGuard on routes you accept will be client-rendered:

src/app/app.routes.ts
export const routes: Routes = [
// Server-rendered: crawlable, cacheable, no auth involved.
{ path: '', loadComponent: () => import('./landing').then((m) => m.Landing) },
{ path: 'pricing', loadComponent: () => import('./pricing').then((m) => m.Pricing) },
{ path: 'docs/:slug', loadComponent: () => import('./article').then((m) => m.Article) },

// Client-rendered: personalised, and not something a crawler should index.
{
path: 'app',
canActivate: [authGuard],
loadChildren: () => import('./app-shell/routes').then((m) => m.routes),
},
];

This is rarely a real loss. Protected pages are personalised by definition — they shouldn't be indexed, and they can't be cached per-user at the edge anyway.

Excluding protected routes from prerendering​

With Angular's outputMode: 'server' or explicit prerender configuration, tell the builder not to prerender guarded paths — a prerender pass would only capture the refusal:

src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'pricing', renderMode: RenderMode.Prerender },
{ path: 'app/**', renderMode: RenderMode.Client },
{ path: '**', renderMode: RenderMode.Server },
];

Guarding your own code​

Anything in your application that calls login(), logout() or getToken() at construction time runs on the server too. Guard it:

export class SessionBootstrap {
private readonly auth = inject(AuthService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

async start(): Promise<void> {
if (!this.isBrowser) {
return;
}
await this.auth.whenReady();
// …
}
}

AuthService state reads (authenticated(), claims(), roles()) are always safe — they just report an unauthenticated user. It's the operations that need a browser.

The error message tells you which

Calling an operation server-side throws with 'Keycloak is unavailable on this platform. Guard authentication calls with isPlatformBrowser() when running with SSR.' If you see that during a build, a prerender pass reached code that assumes a browser.

Avoiding the unauthenticated flash​

The server renders the anonymous view, and the client then restores the session — so a signed-in user can briefly see the signed-out UI after hydration. Branch on ready() so the transition is "loading → signed in" rather than "signed out → signed in":

@Component({
template: `
@if (!auth.ready()) {
<app-header-skeleton />
} @else if (auth.authenticated()) {
<app-user-menu />
} @else {
<a (click)="auth.login()">Sign in</a>
}
`,
})
export class Header {
protected readonly auth = inject(AuthService);
}

On the server ready() is true and authenticated() is false, so the anonymous branch renders — matching what an anonymous visitor should get.

Reserve the space

Render a skeleton with the same dimensions as the signed-in header. It costs a few lines of CSS and removes the layout shift when the session resolves.

Server-side calls to your API​

The bearer interceptor attaches nothing on the server, because nobody is authenticated there. A resolve or a component that fetches during SSR therefore hits your API unauthenticated.

For personalised data, fetch it on the client — inside a guarded route, which is client-rendered anyway. Angular's HttpTransferCache won't help here: there's no session to make the server request on behalf of.

If you need genuine server-side authenticated rendering, that requires a cookie-based session your Node server can read and exchange — an architecture this library doesn't model, since it exists to keep tokens out of persistent storage.

Next​