How to make Strapi Client work with Angular SSR
Strapi Client presentation
Strapi released a client library that works directly with the Strapi API.
You can install it with the following command:
npm install @strapi/client
This will allow you to use an object like this:
import { strapi } from '@strapi/client';
const client = strapi({ baseURL: 'http://localhost:1337/api' });
const articles = client.collection('articles');
// Fetch all English articles sorted by title
const allArticles = await articles.find({
locale: 'en',
sort: 'title',
});
Problem with Angular SSR
Angular application stable / unstable state
In Angular, there is a state of the application that is Stable or Unstable. In SSR, the application waits for the application to become stable before sending the response to the client.
Some tasks keep the application unstable, because they are considered as something that must be finished, which is a good thing. For example, the HTTP requests keep the application unstable. But the way the Strapi library does HTTP requests is outside of Angular context, so they do not keep the application unstable. Which means that if there is nothing else that keeps it unstable while Strapi is finishing its request, it can create a situation where the application becomes stable before the Strapi request is finished.
Here is a simple example, on an Angular 21 project with Strapi client installed.
import {Component, OnInit, signal, WritableSignal} from '@angular/core';
import {API, strapi} from '@strapi/client';
@Component({
selector: 'app-root',
imports: [],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App implements OnInit {
protected categories:WritableSignal<API.Document[]|null> = signal<API.Document[]>([]);
public ngOnInit() {
const client = strapi({ baseURL: 'http://localhost:1337/api' });
const articlesCategory = client.collection('article-categories');
articlesCategory.find({
filters: {
slug: {
$eq: 'help-center',
},
},
status: 'published',
}).then((res: API.DocumentResponseCollection<API.Document>) => {
this.categories.set(res.data);
});
}
}
Article categories:
<ul>
@for (category of categories(); track $index) {
<li>{{ category['title'] }}</li>
}
</ul>
The server response is :
<app-root ng-version="21.2.6" ngh="0" ng-server-context="ssg">
Article categories:
<ul></ul>
</app-root>
While when the browser loads the page, the request is done and the HTML becomes:
<app-root ng-version="21.2.6" ng-server-context="ssg">
Article categories:
<ul>
<li>Help center</li>
</ul>
</app-root>
Unnecessary HTTP requests
In the previous example, the request is done once on the server side and once on the client side.
This is something that is also present in the basic HTTP client of Angular if you don’t do anything special.
But once the problem of the unstable state is fixed, the request could be done only once on the server side.
Solutions
Keep the application unstable
To add my fixes, I created a custom wrapper for the Strapi client. For that I generated a service:
ng g s strapi
A first version of the service can be like the following code. To keep the application unstable, we need to transform the Promise to an Observable and use the pendingUntilEvent operator. Also transforming the Promise to an Observable allows us to use it like we are used to with the HTTP client of Angular.
import {inject, Injectable, Injector} from '@angular/core';
import {API, CollectionTypeManager, strapi, StrapiClient} from '@strapi/client';
import {from, Observable} from 'rxjs';
import {pendingUntilEvent} from '@angular/core/rxjs-interop';
@Injectable()
export class StrapiService {
protected strapiClient: StrapiClient;
private injector: Injector = inject(Injector);
constructor() {
this.strapiClient = strapi({ baseURL: 'http://localhost:1337/api' });
}
public find(
collectionName: string,
params: API.BaseQueryParams,
): Observable<API.DocumentResponseCollection<API.Document>> {
// Create the collection manager
const collection:CollectionTypeManager = this.strapiClient.collection(collectionName);
// Transform the Promise to an Observable
// and make the app unstable until the request is completed by adding pendingUntilEvent
return from(collection.find(params)).pipe(
pendingUntilEvent(this.injector),
);
}
}
Note: I only did it for the find method, but you can do it for the other methods of the Strapi client.
The usage of the service looks like this:
import {Component, inject, OnInit, signal, WritableSignal} from '@angular/core';
import {API} from '@strapi/client';
import {StrapiService} from './strapi.service';
@Component({
selector: 'app-root',
imports: [],
providers:[StrapiService],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App implements OnInit {
private strapiService:StrapiService = inject(StrapiService);
protected categories:WritableSignal<API.Document[]|null> = signal<API.Document[]>([]);
public ngOnInit() {
this.strapiService.find('article-categories',{
filters: {
slug: {
$eq: 'help-center',
},
},
status: 'published',
}).subscribe((res: API.DocumentResponseCollection<API.Document>) => {
this.categories.set(res.data);
});
}
}
And now the HTML from the server response is the same as the one from the client side.
We only need to fix the double HTTP requests.
Save the response from the server side and use it in the client side
To avoid doing the HTTP requests twice, we can save the response from the server side and use it in the client side.
To do so Angular provides the TransferState service. It allows us to share data between the server and the client.
So once the HTTP request is done on the server side, we can save the response in the TransferState and then on the client side, we can read the TransferState and if the data is present we can use it instead of doing the HTTP request.
To save the response I need a unique key that I can use to save and that I can reuse on the client side. So it can’t be a random key that the frontend can’t generate again to read the response.
To generate a unique key I use the collection name and the parameters used in the HTTP request.
I also remove the response from the transfer state after reading it. This is optional depending on your use case.
In my case if the user re-triggers the HTTP request while browsing the application on the client side, I don’t want to use the response from the transfer state, so I read it only once.
Here is the code :
import {inject, Injectable, Injector, makeStateKey, PLATFORM_ID, StateKey, TransferState} from '@angular/core';
import {API, CollectionTypeManager, strapi, StrapiClient} from '@strapi/client';
import {from, Observable, tap} from 'rxjs';
import {pendingUntilEvent} from '@angular/core/rxjs-interop';
import {isPlatformServer} from '@angular/common';
@Injectable()
export class StrapiService {
protected strapiClient: StrapiClient;
private injector: Injector = inject(Injector);
private platformId: object = inject(PLATFORM_ID);
private transferState: TransferState = inject(TransferState);
constructor() {
this.strapiClient = strapi({ baseURL: 'http://localhost:1337/api' });
}
private getUniqueKey(collectionName:string, params: API.BaseQueryParams): string {
const paramsString: string = JSON.stringify(params);
return collectionName + ':' + btoa(paramsString);
}
public find(
collectionName: string,
params: API.BaseQueryParams,
): Observable<API.DocumentResponseCollection<API.Document>> {
const uniqueKey: string = this.getUniqueKey(collectionName,params);
const requestKey: StateKey<API.DocumentResponseCollection<API.Document>> =
makeStateKey(`HTTP_GET_REQUEST_BODY:${uniqueKey}`);
// We check if the response is stored in the transfer state
const storedResponse: API.DocumentResponseCollection<API.Document> | null =
this.transferState.get(requestKey, null);
if (storedResponse) {
this.transferState.remove(requestKey);
return from([storedResponse]);
}else {
// Create the collection manager
const collection: CollectionTypeManager = this.strapiClient.collection(collectionName);
// Transform the Promise to an Observable
// and make the app unstable until the request is completed by adding pendingUntilEvent
return from(collection.find(params)).pipe(
pendingUntilEvent(this.injector),
tap((res: API.DocumentResponseCollection<API.Document>) => {
if (isPlatformServer(this.platformId)) {
this.transferState.set(requestKey, res);
}
}),
);
}
}
}
Now the HTTP request is only done once on the server side and the response is saved in the transfer state.
Some other improvements I did
I wanted to add more type safety to the code, so I changed the StrapiService to use a generic type for the collection name and the parameters and made it abstract.
For each collection I create a new service with the generic type set to the collection name. If needed, I can also apply some specific logic for the collection by overriding the methods.
I also use the API URL from the environment file to make it configurable.
import { isPlatformServer } from '@angular/common';
import {
inject,
Injector,
makeStateKey,
PLATFORM_ID,
StateKey,
TransferState,
} from '@angular/core';
import { pendingUntilEvent } from '@angular/core/rxjs-interop';
import {
API,
CollectionTypeManager,
strapi,
StrapiClient,
} from '@strapi/client';
import { from, map, Observable, tap } from 'rxjs';
import { environment } from '../../../environments/environment';
export abstract class StrapiService<T extends API.Document = API.Document> {
private url: string = environment.content_api;
protected strapiClient: StrapiClient;
protected collection: CollectionTypeManager;
private endpoint: string;
private platformId: object = inject(PLATFORM_ID);
private transferState: TransferState = inject(TransferState);
private injector: Injector = inject(Injector);
constructor(endpoint: string) {
this.strapiClient = strapi({ baseURL: this.url + '/api' });
this.collection = this.strapiClient.collection(endpoint);
this.endpoint = endpoint;
}
private getUniqueKey(params: API.BaseQueryParams): string {
const paramsString: string = JSON.stringify(params);
return this.endpoint + ':' + btoa(paramsString);
}
public find(
params: API.BaseQueryParams,
): Observable<API.DocumentResponseCollection<T>> {
const uniqueKey: string = this.getUniqueKey(params);
const requestKey: StateKey<API.DocumentResponseCollection<T>> =
makeStateKey(`HTTP_GET_REQUEST_BODY:${uniqueKey}`);
// We check if the response is stored in the transfer state
const storedResponse: API.DocumentResponseCollection<T> | null =
this.transferState.get(requestKey, null);
if (storedResponse) {
this.transferState.remove(requestKey);
return from([storedResponse]);
} else {
// Transform the Promise to an Observable
// and make the app unstable until the request is completed by adding pendingUntilEvent
return from(this.collection.find(params)).pipe(
map((res: API.DocumentResponseCollection<API.Document>) => {
return res as API.DocumentResponseCollection<T>;
}),
tap((res: API.DocumentResponseCollection<T>) => {
if (isPlatformServer(this.platformId)) {
this.transferState.set(requestKey, res);
}
}),
pendingUntilEvent(this.injector),
);
}
}
}
import { Injectable } from '@angular/core';
import {StrapiService} from './strapi.service';
import {API} from '@strapi/client';
export class Article implements API.Document {
public id!: number;
public documentId!: string;
public title!: string;
public slug!: string;
public content!: string;
public description!: string;
public tags!: string;
public createdAt!: string;
public updatedAt!: string;
public publishedAt!: string;
}
@Injectable()
export class ArticleCategoriesService extends StrapiService<Article> {
constructor() {
super('article-categories');
}
}
import {Component, inject, OnInit, signal, WritableSignal} from '@angular/core';
import {API} from '@strapi/client';
import {ArticleCategoriesService} from './article-categories.service';
@Component({
selector: 'app-root',
imports: [],
providers:[ArticleCategoriesService],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App implements OnInit {
private articleCategoriesService:ArticleCategoriesService = inject(ArticleCategoriesService);
protected categories:WritableSignal<API.Document[]|null> = signal<API.Document[]>([]);
public ngOnInit() {
this.articleCategoriesService.find({
filters: {
slug: {
$eq: 'your-slug',
},
},
status: 'published',
}).subscribe((res: API.DocumentResponseCollection<API.Document>) => {
this.categories.set(res.data);
});
}
}