Ecommerce depends on search traffic. A product page that crawlers can’t index doesn’t exist to Google. A checkout page with a 3-second API-dependent first paint loses customers before they’ve seen the price.

Angular 21’s SSR story has matured significantly. Route-level rendering modes, stable hydration with event replay, and @defer for progressive loading give you the tools to build a storefront that’s both Google-friendly and performant. This is Part 7 of the Angular Ecommerce Playbook.

📊 Download: SSR Rendering Flow Diagram (draw.io)

Angular 21 SSR Architecture

Angular’s SSR uses Angular Universal (now fully integrated into the Angular CLI). In Angular 21, there’s no separate package — SSR is a first-class CLI feature.

# Add SSR to the existing project
ng add @angular/ssr

# Or generate a new project with SSR from the start
ng new techshop-storefront --ssr

This generates:

  • app.config.ts — client configuration
  • app.config.server.ts — server-only configuration
  • server.ts — Express server entry point
  • Updated app.routes.ts with RenderMode imports

Route-Level Rendering Modes

This is Angular 21’s killer SSR feature for ecommerce. Each route can have a different rendering strategy:

// app.config.server.ts — server-side configuration
import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
import { provideServerRendering, RenderMode, ServerRoute } from '@angular/ssr';
import { appConfig } from './app.config';

const serverRoutes: ServerRoute[] = [
  {
    // Home page — SSG (built once, served as static HTML — fastest TTFB)
    path: '',
    renderMode: RenderMode.Prerender,
  },
  {
    // Product catalog — SSR (personalized pricing, real-time stock)
    path: 'products',
    renderMode: RenderMode.Server,
  },
  {
    // Product detail — SSR with dynamic params (SEO critical)
    path: 'products/:slug',
    renderMode: RenderMode.Server,
  },
  {
    // Cart — CSR only (session data, no SEO benefit)
    path: 'cart',
    renderMode: RenderMode.Client,
  },
  {
    // Checkout — CSR only (auth required, no indexing)
    path: 'checkout/**',
    renderMode: RenderMode.Client,
  },
  {
    // Account — CSR only
    path: 'account/**',
    renderMode: RenderMode.Client,
  },
  {
    // Static pages — SSG (prerendered at build time)
    path: 'about',
    renderMode: RenderMode.Prerender,
  },
  {
    // Everything else — SSR default
    path: '**',
    renderMode: RenderMode.Server,
  },
];

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    provideServerRoutesConfig(serverRoutes),
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

The performance rationale:

RouteModeReason
/Prerender (SSG)Static, same for all users, fastest delivery
/products/:slugServer (SSR)SEO critical, stock varies, personalized pricing
/cartClient (CSR)Pure session state, no crawlers access
/checkout/**Client (CSR)Auth-gated, compliance (no server logging of payment data)
/about, /faqPrerender (SSG)Marketing content, never changes

Hydration with Event Replay

Angular 21 ships withEventReplay() as stable. This solves the “interaction before hydration” problem — a user clicking “Add to Cart” while the page is still hydrating (common on slow mobile connections).

// app.config.ts
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideClientHydration(
      withEventReplay() // Captures events pre-hydration, replays post-hydration
    ),
    // ...
  ],
};

What happens without withEventReplay():

  1. Server renders HTML — user sees product page
  2. User clicks “Add to Cart” at 200ms
  3. Angular hydrates components at 800ms
  4. The 200ms click is lost — user has to click again
  5. Customer frustration

With withEventReplay():

  1. Server renders HTML — user sees product page
  2. User clicks “Add to Cart” at 200ms — event is queued
  3. Angular hydrates at 800ms
  4. Queued “Add to Cart” click is replayed
  5. Cart updates correctly — seamless experience

SEO: Meta Tags and Structured Data

Dynamic Meta Tags per Product

// libs/catalog/feature-product-detail/src/lib/product-detail.component.ts
import { Component, inject, input, OnInit } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';

@Component({
  selector: 'ts-product-detail',
  standalone: true,
  // ...
})
export class ProductDetailComponent implements OnInit {
  readonly product = input.required<Product>();
  private readonly meta = inject(Meta);
  private readonly title = inject(Title);

  ngOnInit(): void {
    const p = this.product();

    // Open Graph + Twitter Card + standard meta
    this.title.setTitle(`${p.name} — TechShop`);

    this.meta.updateTag({ name: 'description', content: p.description.slice(0, 160) });

    // Open Graph (Facebook, LinkedIn, WhatsApp previews)
    this.meta.updateTag({ property: 'og:title', content: p.name });
    this.meta.updateTag({ property: 'og:description', content: p.description.slice(0, 160) });
    this.meta.updateTag({ property: 'og:image', content: p.images[0]?.url ?? '' });
    this.meta.updateTag({ property: 'og:url', content: `https://techshop.com/products/${p.slug}` });
    this.meta.updateTag({ property: 'og:type', content: 'product' });
    this.meta.updateTag({ property: 'product:price:amount', content: p.price.toString() });
    this.meta.updateTag({ property: 'product:price:currency', content: 'USD' });

    // Twitter Card
    this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' });
    this.meta.updateTag({ name: 'twitter:title', content: p.name });
    this.meta.updateTag({ name: 'twitter:image', content: p.images[0]?.url ?? '' });
  }
}

Structured Data (JSON-LD) for Google Shopping

Google Shopping and rich results require Product structured data. In Angular SSR, inject it in the <head> during server rendering:

// libs/shared/seo/src/lib/json-ld.service.ts
import { Injectable, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';

@Injectable({ providedIn: 'root' })
export class JsonLdService {
  private readonly doc = inject(DOCUMENT);

  setProduct(product: Product): void {
    const schema = {
      '@context': 'https://schema.org',
      '@type': 'Product',
      name: product.name,
      description: product.description,
      sku: product.id,
      image: product.images.map(img => img.url),
      offers: {
        '@type': 'Offer',
        url: `https://techshop.com/products/${product.slug}`,
        priceCurrency: 'USD',
        price: product.price,
        availability: product.isInStock
          ? 'https://schema.org/InStock'
          : 'https://schema.org/OutOfStock',
        seller: { '@type': 'Organization', name: 'TechShop' },
      },
    };

    // Remove existing schema script if any
    this.doc.querySelector('#product-jsonld')?.remove();

    const script = this.doc.createElement('script');
    script.id = 'product-jsonld';
    script.type = 'application/ld+json';
    script.text = JSON.stringify(schema);
    this.doc.head.appendChild(script);
  }
}

With SSR, the JSON-LD is in the HTML when Google crawls it — no JavaScript execution required by the crawler.

TransferState: Avoid Duplicate HTTP Requests

Without transfer state, SSR fetches product data on the server, renders HTML, then the hydrated client fetches product data again on startup. The user sees a flash and the API gets hit twice.

// In the product detail component
import { inject } from '@angular/core';
import { TransferState, makeStateKey } from '@angular/core';

const PRODUCT_KEY = makeStateKey<Product>('product');

@Component({ /* ... */ })
export class ProductDetailComponent implements OnInit {
  private transferState = inject(TransferState);
  private api = inject(TechShopApiClient);
  private isPlatformBrowser = inject(PLATFORM_ID);

  readonly productResource = resource({
    request: () => ({ slug: this.slug() }),
    loader: async ({ request }) => {
      // Check TransferState (populated by server) before making HTTP call
      const cached = this.transferState.get(PRODUCT_KEY, null);
      if (cached) {
        this.transferState.remove(PRODUCT_KEY); // Consume once
        return cached;
      }

      // Server or client fetch
      const product = await this.api.products.bySlug(request.slug).get();

      // If on server, store in TransferState for client to pick up
      if (isPlatformServer(this.isPlatformBrowser)) {
        this.transferState.set(PRODUCT_KEY, product);
      }

      return product;
    }
  });
}

Angular 21 has built-in HTTP caching via withHttpTransferCache() in provideClientHydration() — but resource() requires manual TransferState handling (as shown above). The Angular team is improving this integration in 2026.

Performance: Core Web Vitals Targets

For an ecommerce storefront, Core Web Vitals targets:

MetricTargetAngular 21 Technique
LCP (Largest Contentful Paint)< 2.5sSSR + NgOptimizedImage with priority
CLS (Cumulative Layout Shift)< 0.1Pre-set image dimensions, skeleton loaders
INP (Interaction to Next Paint)< 200msZoneless + OnPush + Signals
TTFB (Time to First Byte)< 800msSSG for static, CDN edge for SSR
// NgOptimizedImage — priority for LCP image
@Component({
  imports: [NgOptimizedImage],
  template: `
    <img
      [ngSrc]="product().images[0].url"
      [alt]="product().name"
      width="600"
      height="600"
      priority          <!-- Preloads this image — drives LCP improvement -->
      sizes="(max-width: 768px) 100vw, 50vw"
    />
  `
})
// Skeleton loader pattern for CLS prevention
@Component({
  template: `
    @if (productResource.isLoading()) {
      <ts-product-skeleton />  <!-- Same dimensions as real content — prevents CLS -->
    } @else {
      <ts-product-card [product]="productResource.value()!" />
    }
  `
})

The SSR Deployment

Angular SSR requires a Node.js server. For TechShop, we deploy to Azure Container Apps with a custom Express server:

// server.ts (generated by ng add @angular/ssr, customized)
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr/node';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './src/main.server';

export function app(): express.Express {
  const server = express();
  const serverDistFolder = dirname(fileURLToPath(import.meta.url));
  const browserDistFolder = resolve(serverDistFolder, '../browser');

  server.set('view engine', 'html');
  server.set('views', browserDistFolder);

  // Serve static assets from /browser
  server.get('**', express.static(browserDistFolder, {
    maxAge: '1y',
    index: false,
  }));

  // All regular routes use the Angular SSR engine
  server.get('**', (req, res, next) => {
    const { protocol, originalUrl, baseUrl, headers } = req;
    commonEngine.render({
      bootstrap,
      documentFilePath: join(browserDistFolder, 'index.html'),
      url: `${protocol}://${headers.host}${originalUrl}`,
      publicPath: browserDistFolder,
      providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
    })
      .then(html => res.send(html))
      .catch(next);
  });

  return server;
}

The Angular SSR + .dockerignore + GitHub Actions deployment is covered in detail in Part 10 (CI/CD).


References


This is Part 7 of 11 in the Angular Ecommerce Playbook. ← Part 6: State Management | Part 8: GitHub Copilot Workflow →

Export for reading

Comments