Pre-rendering Targeted Sections

How to pre-render specific routes as static HTML in GoGrab Space NX apps using Angular 20's route-level RenderMode.

Pre-rendering Targeted Sections

The Problem

Angular SPAs serve an empty HTML shell + JavaScript bundles. Bots, crawlers, link validators (Twilio, Google, curl) see zero content — only a <router-outlet> and a loading spinner. The actual page content only exists after JavaScript executes.

This means:

The Solution: Angular 20 Route-Level Prerendering

Angular 20 provides route-level RenderMode via @angular/ssr. This lets you prerender specific routes as static HTML at build time while keeping the rest of the app as a standard SPA.

Key points:

Architecture

Build Time:
  Angular compiler renders /legal/privacy-policy → generates index.html with full content

Runtime (bot visits /legal/privacy-policy):
  Server returns pre-built index.html → bot sees full legal text ✅

Runtime (browser visits /legal/privacy-policy):
  Server returns pre-built index.html → Angular hydrates → language toggle works ✅

Runtime (browser visits /dashboard):
  Server returns SPA shell → Angular renders client-side (unchanged behavior)

Implementation Guide

Prerequisites

Verify these packages are installed (they should be in any Angular 20 project):

# Both should already be in package.json
@angular/ssr
@angular/platform-server

Step 1: Create Server Entry Point

File: apps/<app-name>/src/main.server.ts

import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';

const bootstrap = () => bootstrapApplication(AppComponent, config);
export default bootstrap;

Step 2: Define Server Route Configuration

File: apps/<app-name>/src/app/app.routes.server.ts

import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  // PRERENDERED — full HTML at build time
  { path: 'legal', renderMode: RenderMode.Prerender },
  { path: 'legal/privacy-policy', renderMode: RenderMode.Prerender },
  { path: 'legal/terms-of-use', renderMode: RenderMode.Prerender },

  // CLIENT-ONLY — standard SPA (catch-all MUST be last)
  { path: '**', renderMode: RenderMode.Client },
];

Available render modes:

Mode When HTML is generated Node.js server needed? Use case
RenderMode.Prerender Build time No Static content: legal, docs, landing, marketing
RenderMode.Server Request time Yes Dynamic content that must be SEO-visible
RenderMode.Client Browser runtime No App dashboards, authenticated pages

Step 3: Create Server Config

File: apps/<app-name>/src/app/app.config.server.ts

import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';

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

export const config = mergeApplicationConfig(appConfig, serverConfig);

Step 4: Create Server tsconfig

File: apps/<app-name>/tsconfig.server.json

{
  "extends": "./tsconfig.app.json",
  "compilerOptions": {
    "outDir": "../../dist/out-tsc/server",
    "types": ["node"]
  },
  "files": ["src/main.server.ts"],
  "include": ["src/**/*.ts"]
}

Step 5: Update project.json

Add server entry point and outputMode to the build target:

{
  "build": {
    "executor": "@angular-devkit/build-angular:application",
    "options": {
      "browser": "apps/<app-name>/src/main.ts",
      "server": "apps/<app-name>/src/main.server.ts",
      "outputMode": "static",
      ...
    }
  }
}

Step 6: Add Client Hydration

In app.config.ts, add provideClientHydration():

import { provideClientHydration } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(),
    // ... existing providers
  ]
};

Step 7: Make App Initializers SSR-Safe

This is the critical step. During prerendering, Angular bootstraps your app in Node.js. All provideAppInitializer callbacks run — but browser APIs (window, document, localStorage, IndexedDB) and HTTP backends are not available.

Pattern: Guard every browser-dependent initializer:

import { PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

provideAppInitializer(() => {
  if (!isPlatformBrowser(inject(PLATFORM_ID))) return Promise.resolve(); // SSR: skip
  // ... browser-only initialization
  return someAsyncOperation();
}),

What needs guarding:

What does NOT need guarding:

Step 8: Make AppComponent SSR-Safe

The root AppComponent typically has browser-only code in ngOnInit. Guard it:

export class AppComponent implements OnInit {
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  ngOnInit(): void {
    if (!this.isBrowser) return; // SSR: skip all browser init
    // ... existing browser-only initialization
  }

  ngOnDestroy(): void {
    if (!this.isBrowser) return;
    // ... cleanup
  }
}

Step 9: Build and Verify

nx build <app-name>

Check the output:

dist/apps/<app-name>/browser/
  index.html                          ← SPA shell
  legal/index.html                    ← PRERENDERED ✅
  legal/privacy-policy/index.html     ← PRERENDERED ✅
  legal/terms-of-use/index.html       ← PRERENDERED ✅

Verify content is present:

# Should show actual legal text, not just JS bundles
grep "GOGRAB LLC" dist/apps/<app-name>/browser/legal/privacy-policy/index.html

Build Output Structure

With outputMode: 'static', the output is a standard static site:

dist/apps/gograb-space/browser/
├── index.html                    ← SPA shell (all RenderMode.Client routes)
├── legal/
│   ├── index.html                ← Prerendered Legal Center hub
│   ├── privacy-policy/
│   │   └── index.html            ← Prerendered Privacy Policy (full content)
│   └── terms-of-use/
│       └── index.html            ← Prerendered Terms of Use (full content)
├── main-[hash].js                ← App bundle
├── polyfills-[hash].js
├── styles-[hash].css
└── chunk-[hash].js               ← Lazy-loaded chunks

Deployment: Serve the browser/ directory from any static host (Cloudflare Pages, S3, Netlify). No Node.js server required.

Adding New Prerendered Routes

To prerender a new route:

  1. Add to app.routes.server.ts:

    { path: 'about', renderMode: RenderMode.Prerender },
    
  2. Ensure the component is SSR-safe:

    • No window/document access during component initialization
    • No HTTP calls during initialization (static content only)
    • Any browser-only code should be in click handlers or afterNextRender
  3. Rebuild: nx build <app-name>

  4. Verify: Check dist/.../browser/about/index.html has content

Parameterized Routes

For routes like /blog/:slug, provide parameters at build time:

{
  path: 'blog/:slug',
  renderMode: RenderMode.Prerender,
  fallback: PrerenderFallback.Client,  // non-prerendered slugs fall back to CSR
  async getPrerenderParams() {
    const blogService = inject(BlogService);
    const slugs = await blogService.getAllSlugs();
    return slugs.map(slug => ({ slug }));
  },
},

Known Limitations

Query Parameters Not Prerendered

Prerendering captures the default state. For our legal pages, ?lang=es (Spanish) is only resolved after hydration. Bots see the English version. If Spanish must be bot-visible:

Browser API Contamination

Even though you only prerender 3 routes, the SSR build compiles the entire app for the server. Any eagerly-imported code that touches window/document at module load time will break. Key defenses:

Build Time

Each prerendered route adds ~2-5 seconds to build time. For 3 legal pages, this is negligible. For 1000+ pages, consider using RenderMode.Server instead.


Recommendations: Full Rendering Strategy for GoGrab Apps

Current State (Session 118)

App Current Rendering Ideal Rendering
gograb-space SPA (client-only) Hybrid: prerender legal + landing, CSR for app
docs-angular SPA (client-only) Full prerender — all docs should be static
gograb-admin SPA (client-only) CSR only — admin is authenticated, no SEO needed
gograb-ai SPA (client-only) CSR only — embedded in extension, no SEO

Phase 1: Legal Pages (Done — Session 118)

Status: Implemented. Legal routes prerendered at build time. Impact: Compliance bots (Twilio, regulators) see full content.

Phase 2: Landing & Marketing Pages

Target: Prerender /landing, /pricing, /features, /about Blocker: LandingComponent uses GuestAuthService, SpaceService in handleGetStarted(). These are click handlers (not init), so they should work. Test and add.

// app.routes.server.ts — Phase 2 addition
{ path: 'landing', renderMode: RenderMode.Prerender },

Phase 3: docs-angular — Full Static Site Generation

The docs-angular app is the strongest candidate for full prerendering. Every documentation page should be:

Strategy:

  1. Add @angular/ssr + main.server.ts to docs-angular
  2. Set outputMode: 'static' in project.json
  3. In app.routes.server.ts:
    // Prerender ALL doc pages
    { path: '**', renderMode: RenderMode.Prerender },
    
  4. For dynamic routes, use getPrerenderParams() to enumerate all doc slugs at build time
  5. Deploy the browser/ folder as a fully static site

Expected result: Every URL in docs-angular serves complete HTML. Google indexes all pages. Users without JS (curl, screen readers, bots) see full content.

Phase 4: Shared SSR Utilities Library

Once 2+ apps use prerendering, extract common patterns into a shared library:

libs/libs-foundational/ssr-utils/
├── src/lib/
│   ├── guards/platform-browser.guard.ts     ← isPlatformBrowser helper
│   ├── providers/ssr-safe-initializer.ts     ← Wrapper for provideAppInitializer
│   └── testing/prerender-verifier.ts         ← Build-time verification
└── src/index.ts

ssrSafeInitializer helper:

export function ssrSafeInitializer(fn: () => void | Promise<void>) {
  return provideAppInitializer(() => {
    if (!isPlatformBrowser(inject(PLATFORM_ID))) return Promise.resolve();
    return fn();
  });
}

This eliminates the need for every initializer to manually check isPlatformBrowser.

Phase 5: SEO Metadata & Structured Data

Prerendering alone is not enough. For full SEO effectiveness:

  1. Meta tags per route — Use Angular's Meta and Title services to set <meta name="description">, Open Graph tags, etc.
  2. Structured data (JSON-LD) — Add schema.org markup for legal pages, organization info
  3. Sitemap generation — Build a /sitemap.xml from the prerendered route list
  4. Canonical URLs — Prevent duplicate content issues between ?lang=en and ?lang=es

Decision Matrix: When to Prerender

Criterion Prerender Server Render Client Only
Content changes rarely Yes No No
Must be SEO-indexed Yes Yes No
Behind authentication No No Yes
Has dynamic user data No Yes Yes
High traffic (cacheable) Yes Maybe Yes
Legal/compliance requirement Yes Yes No
Real-time data needed No No Yes

Files Reference (gograb-space — Session 118)

File Purpose
apps/gograb-space/src/main.server.ts Server bootstrap for prerendering
apps/gograb-space/src/app/app.config.server.ts Server config (merges with client config)
apps/gograb-space/src/app/app.routes.server.ts Route render mode definitions
apps/gograb-space/tsconfig.server.json Server TypeScript config
apps/gograb-space/project.json Build config with server + outputMode: 'static'
apps/gograb-space/src/app/app.config.ts Client config with provideClientHydration() + SSR guards
apps/gograb-space/src/app/app.component.ts Root component with isBrowser guard
libs/libs-structural/legal-lib/ Legal pages library (prerendered content)