#Help Needed: Angular SSR Route Parameter slug Null on Server Side

5 messages · Page 1 of 1 (latest)

devout whale
#

Help Needed: Angular SSR Route Parameter slug Null on Server Side

Hey Angular folks! I'm hitting a wall with Angular SSR (v17) where the slug param in my products/:slug route is null on the server, breaking my product loading. It works fine client-side, but server-side rendering fails to pick up the slug. I suspect it's a timing issue with paramMap observables in SSR. Here's my setup—any ideas on what's going wrong or how to fix it?

Setup:

  • Angular 17, SSR enabled
  • Lazy-loaded component with products/:slug route
  • Using TransferState in my service
  • Issue: this.route.paramMap gives null slug on server
#

Routing Config:

// src/app/app-routing.module.ts
{
  path: 'products/:slug',
  loadComponent: () => import('./components/products/products-main.component').then(m => m.ProductsMainComponent),
},
{
  path: 'products/:slug',
  renderMode: RenderMode.Server,
  status: 200,
}, 
#

Component: This grabs the slug and fetches products, but slug is null server-side.


@Component({
  selector: 'app-products-main',
  imports: [CommonModule, RouterModule],
  templateUrl: './products-main.component.html',
  styleUrl: './products-main.component.scss',
})
export class ProductsMainComponent {
  private productsService = inject(SsrProductsService);
  private route = inject(ActivatedRoute);
  private destroy$ = new Subject<void>();

  productSlug: string | null = null;
  products: CustomerProductResponse[] = [];
  loading = true;
  error: string | null = null;

  ngOnInit(): void {
    this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe((params) => {
      this.productSlug = params.get('slug');
      this.loadProducts();
    });
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  private loadProducts(): void {
    this.loading = true;
    this.error = null;

    const filters: CustomerProductRequest = {
      pageNumber: 1,
      pageSize: 10,
      orderby: ['name'],
      tagsSlugs: this.productSlug ? [this.productSlug] : [],
    };

    console.log('Loading products with filters:', filters);

    this.productsService
      .getProductsCustomer(filters)
      .pipe(takeUntil(this.destroy$))
      .subscribe({
        next: (response) => {
          this.products = response.data;
          this.loading = false;
        },
        error: (err) => {
          console.error('Error loading products:', err);
          this.error = 'Failed to load products';
          this.loading = false;
        },
      });
  }
}```
#

Service: Handles API calls with TransferState for SSR caching.


export class SsrProductsService {
  private readonly baseUrl = environment.apiUrl + 'Products/';
  private readonly endpoints = {
    customerProducts: 'get-customer-products',
  };

  private readonly http = inject(HttpClient);
  private readonly transferState = inject(TransferState);

  private postWithTransferState<T, R>(
    url: string,
    body: T,
    stateKey: StateKey<R>,
    httpCall: () => Observable<R>
  ): Observable<R> {
    const cachedData = this.transferState.get(stateKey, null);
    if (cachedData !== null) {
      this.transferState.remove(stateKey);
      return of(cachedData);
    }
    return httpCall().pipe(
      tap((data) => {
        this.transferState.set(stateKey, data);
      })
    );
  }

  private generateStateKey<T>(methodName: string, params?: any): StateKey<T> {
    const keyString = params
      ? `${methodName}_${JSON.stringify(params)}`
      : methodName;
    return makeStateKey<T>(keyString);
  }

  getProductsCustomer(
    customerProductRequest: CustomerProductRequest
  ): Observable<Result<CustomerProductResponse[]>> {
    const stateKey = this.generateStateKey<Result<CustomerProductResponse[]>>(
      'getProductsCustomer',
      customerProductRequest
    );
    const url = this.baseUrl + this.endpoints.customerProducts;
    return this.postWithTransferState(
      url,
      customerProductRequest,
      stateKey,
      () =>
        this.http.post<Result<CustomerProductResponse[]>>(
          url,
          customerProductRequest,
          { headers: { 'Accept-Language': 'en-US' } }
        )
    );
  }
}
#

What I've Tried:

  • Suspecting a timing issue with paramMap in SSR since server rendering is synchronous.
  • Tested a resolver approach to fetch slug and products, but I'm not 100% sure it works yet.
  • Confirmed the route (http://localhost:4000/products/dtf-direct-to-film) works client-side, but server-side logs show productSlug as null.