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:
- Legal pages (Privacy Policy, Terms of Use) are invisible to compliance bots
- SEO-critical pages (landing, docs, marketing) are not indexed properly
- Social sharing (Open Graph, Twitter cards) shows blank previews
- Accessibility tools that don't execute JS see nothing
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:
- No Node.js server needed at runtime (
outputMode: 'static') - Prerendered routes serve full HTML — bots see complete content
- The browser then hydrates the static HTML — interactive features work normally
- Non-prerendered routes remain pure client-side (zero change in behavior)
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:
- HTTP calls (
firstValueFrom(...),HttpClient) - Storage access (
localStorage,sessionStorage, IndexedDB) - DOM manipulation (
document.getElementById(...),window.addEventListener(...)) - WebSocket connections
- Third-party SDKs (Sentry, analytics, etc.)
What does NOT need guarding:
- Signal creation (
signal()) inject()calls- Pure computation
- Router configuration
- Static constant access
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:
Add to
app.routes.server.ts:{ path: 'about', renderMode: RenderMode.Prerender },Ensure the component is SSR-safe:
- No
window/documentaccess during component initialization - No HTTP calls during initialization (static content only)
- Any browser-only code should be in click handlers or
afterNextRender
- No
Rebuild:
nx build <app-name>Verify: Check
dist/.../browser/about/index.htmlhas 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:
- Option A: Separate routes (
/legal/es/privacy-policy) — best for SEO - Option B: Accept English-only for bots — sufficient for most compliance needs
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:
- Use
isPlatformBrowser()guards in allprovideAppInitializercallbacks - Use
afterNextRender()for browser-only setup in components - Keep
RenderMode.Clientas the catch-all for everything that isn't explicitly prerendered
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:
- Fully indexable by search engines
- Accessible without JavaScript
- Cacheable at the CDN level
Strategy:
- Add
@angular/ssr+main.server.tstodocs-angular - Set
outputMode: 'static'in project.json - In
app.routes.server.ts:// Prerender ALL doc pages { path: '**', renderMode: RenderMode.Prerender }, - For dynamic routes, use
getPrerenderParams()to enumerate all doc slugs at build time - 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:
- Meta tags per route — Use Angular's
MetaandTitleservices to set<meta name="description">, Open Graph tags, etc. - Structured data (JSON-LD) — Add schema.org markup for legal pages, organization info
- Sitemap generation — Build a
/sitemap.xmlfrom the prerendered route list - Canonical URLs — Prevent duplicate content issues between
?lang=enand?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) |