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