Angular SSR and Signals in Practice: Data, SEO and Real Status Codes
Server-rendering CMS-driven pages in Angular: per-request rendering, the HTTP transfer cache, a signal-based loading state, real 404 and 503 responses, SEO tags set on the server and keeping API keys out of the browser.

Server-side rendering is the reason this portfolio's project and blog pages show up properly in search results and link previews. Getting SSR to work in Angular is easy now; getting it to behave like a real website takes a few more decisions. These are the ones I made for pages whose content comes from an API, written with signals.
Render per request, not at build time
Prerendering is great for content that only changes with a deploy. Here every project and post is edited in an admin panel, so prerendered HTML would go stale, and the build would need the API to be running. The server routes say so explicitly:
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [{ path: '**', renderMode: RenderMode.Server }];
Don't fetch everything twice
Without extra configuration, the server fetches the post, renders it, and then the browser fetches it again during hydration. Angular's HTTP transfer cache serialises the server's responses into the page so the client reuses them:
provideHttpClient(withFetch(), withInterceptors([publicSessionInterceptor])),
provideClientHydration(
withHttpTransferCacheOptions({ includePostRequests: false, includeRequestsWithCredentials: true }),
withIncrementalHydration(),
),
One option deserves a comment. Angular skips requests sent with credentials by default, because caching a personalised response into public HTML would be a data leak. My API client sends withCredentials, but on the server those requests carry no visitor cookies, only a server key, so every cached response is public data. That is the only reason includeRequestsWithCredentials is safe here; check your own case before copying it.
The result is that a first page view makes no browser API calls at all. withIncrementalHydration() then lets @defer (hydrate on viewport) blocks, like the footer, render on the server but load their JavaScript only when needed.
Keep secrets on the server
The public API requires either a visitor session or a server key. The interceptor picks one based on where it runs:
export const publicSessionInterceptor: HttpInterceptorFn = (req, next) => {
if (!isPlatformBrowser(inject(PLATFORM_ID))) {
const ssrKey = inject(SSR_API_KEY); // provided only in the server config
return next(ssrKey ? req.clone({ setHeaders: { 'X-SSR-Key': ssrKey } }) : req);
}
// In the browser: attach the visitor's short-lived session token instead.
return next(req);
};
The key is read from the environment in server.ts and provided through app.config.server.ts, so it never reaches the browser bundle.
Model the page state as a union
A content page is in one of four states: loading, loaded, not found, or failed. A discriminated union makes the template and the SEO logic exhaustive and keeps impossible combinations out:
type PostState =
| { status: 'loading' }
| { status: 'loaded'; post: BlogPost }
| { status: 'not-found' }
| { status: 'error' };
export class BlogPostPage {
readonly slug = input.required<string>(); // bound from the route with withComponentInputBinding()
private readonly api = inject(PublicApiService);
protected readonly state = toSignal(
toObservable(this.slug).pipe(
switchMap((slug) =>
this.api.getPost(slug).pipe(
map((post): PostState => ({ status: 'loaded', post })),
catchError((err: unknown) =>
of<PostState>(
err instanceof HttpErrorResponse && err.status === 404
? { status: 'not-found' }
: { status: 'error' },
),
),
startWith<PostState>({ status: 'loading' }),
),
),
),
{ initialValue: { status: 'loading' } as PostState },
);
protected readonly post = computed(() => {
const state = this.state();
return state.status === 'loaded' ? state.post : null;
});
}
Why RxJS inside a signals component? Because the slug can change while the component stays alive (navigating from one post to the next), and switchMap cancels the previous request for free. toObservable and toSignal are the bridge: the stream does the async work, and the template reads plain signals.
The server waits for this request before sending HTML. Angular tracks pending HttpClient requests and only serialises the page once the application is stable, so the rendered HTML contains the loaded post, not the loading state.
The template then branches on the state:
@switch (state().status) {
@case ('loading') { <app-page-loader /> }
@case ('not-found') { <app-empty-state title="Post not found" /> }
@case ('error') { <app-empty-state title="Could not load this post" /> }
@case ('loaded') {
@if (post(); as p) {
<article>
<h1>{{ p.title }}</h1>
<div class="prose" [innerHTML]="p.content"></div>
</article>
}
}
}
Send real status codes
A classic SSR mistake is a "Post not found" page served with 200 OK. Search engines index it as a real page (a soft 404). Angular exposes the outgoing response as the RESPONSE_INIT token on the server, and setting its status from an effect is enough:
private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); // null in the browser
constructor() {
effect(() => {
const state = this.state();
if (state.status === 'loaded') {
this.applySeo(state.post);
} else if (state.status === 'not-found' || state.status === 'error') {
if (this.responseInit) this.responseInit.status = state.status === 'not-found' ? 404 : 503;
this.seo.update({ title: 'Post not found', robots: 'noindex, follow' });
}
});
}
A missing post returns a 404 and a noindex tag. An API outage returns a 503, which tells crawlers to come back later instead of dropping the page from the index.
SEO tags belong in the server HTML
The same effect sets the title, description, canonical URL, Open Graph tags and JSON-LD (BlogPosting plus a BreadcrumbList) through Angular's Title and Meta services. Because it runs during SSR, crawlers and link-preview bots that never execute JavaScript still see complete tags. Each field falls back sensibly: the SEO title to the post title, the meta description to the excerpt, the share image to the cover.
Checklist
- Render CMS-driven routes per request.
- Enable the HTTP transfer cache, and only cache public data.
- Provide server-only keys in the server config, never in the browser one.
- Model page state as a union and derive the view with
computed. - Set 404 and 503 through
RESPONSE_INIT, withnoindexon error pages. - Set every SEO tag during server rendering.
None of these steps is large, but together they are the difference between an Angular app that happens to render on the server and a website that search engines and social platforms treat properly.
