Server-Side Rendering & Hybrid Rendering
AngularDart supports Server-Side Rendering (SSR) and Hybrid Rendering, allowing you to render your application on the server before sending HTML to the client. This dramatically improves initial page load performance, SEO, and social media sharing while keeping all the interactivity of a full SPA on the client side through hydration.
Table of Contents
- What is Server-Side Rendering?
- Why SSR Matters for AngularDart
- How It Works: The Complete Flow
- Architecture Overview
- Creating a Project with SSR
- Project Structure
- Understanding Each File
- Building and Running
- Render Modes
- Hybrid Rendering in Detail
- TransferState: Sharing Data Between Server and Client
- Server-Side Routing
- Building a Custom SSR Server
- Advanced: Per-Component Render Mode Configuration
- Prerendering vs Real-Time SSR
- Common Patterns and Best Practices
- Troubleshooting
What is Server-Side Rendering?
When you build a traditional web application (like an AngularDart SPA), the browser downloads an almost empty HTML page containing only a few tags like <app-component></app-component>. The browser then downloads JavaScript, executes it, and only after that does your content appear on screen. This is called Client-Side Rendering (CSR).
Server-Side Rendering (SSR) flips this model: the server runs your AngularDart application, generates the full HTML with all content already rendered, and sends that complete HTML to the browser. The user sees the page immediately without waiting for JavaScript to download and execute.
Think of it like ordering food at a restaurant:
- CSR (Client-Side): You get raw ingredients and have to cook everything yourself. The kitchen is empty until you start cooking.
- SSR: The chef has already cooked your meal. It's ready the moment you sit down. When you arrive at the table, the food is there hot and complete.
Why SSR Matters for AngularDart
1. Faster First Paint
With CSR, users see a blank screen while JavaScript downloads, parses, and executes. With SSR, content appears instantly because the HTML is already rendered. This is especially important for:
- Users on slow mobile networks (3G/4G)
- Devices with limited processing power (budget phones, tablets)
- Large applications with heavy JavaScript bundles
2. Better SEO (Search Engine Optimization)
While modern search engines like Google can execute JavaScript and index SPA content, it's not always reliable or immediate. With SSR:
- Search engine crawlers see fully rendered HTML exactly what users see
- Content is indexed immediately, without waiting for JavaScript execution
- Meta tags, headings, and structured data are present in the initial HTML
3. Better Social Media Sharing
When you share a link on social media (Twitter, Facebook, LinkedIn), these platforms fetch your page to generate preview cards with titles, descriptions, and images. Without SSR:
- The crawler sees an empty
<app-component></app-component>tag - No title, no description, no image just a bare URL
With SSR, the crawler gets fully rendered HTML with all meta tags populated.
4. Progressive Enhancement
SSR doesn't replace your SPA. After the server-rendered HTML loads, AngularDart hydrates it attaching event listeners and making the page interactive. The user sees content immediately (from SSR), then the app becomes fully interactive (via hydration). This gives you the best of both worlds: fast initial load + rich interactivity.
How It Works: The Complete Flow
Let's walk through what happens from the moment a user types your URL to the moment they're interacting with your app.
Step 1: User Requests a Page
User browser -> GET /about -> Your Server
The browser sends an HTTP request for /about to your server.
Step 2: Server Renders the Component
Your server (running Dart on the VM) receives the request and tells AngularDart to render your application component but instead of rendering to DOM (which doesn't exist on the server), it renders to an HTML string.
// On the server, running in Dart VM:
final html = await server.renderApplication(
ng.appComponentFactory,
url: request.uri.toString(),
);
// html is now a complete HTML document with your content!
Step 3: Server Sends Complete HTML
The server sends back a full HTML document:
<!DOCTYPE html>
<html lang="en" ng-server-context="ssr">
<head>
<meta charset="UTF-8">
<title>About - My App</title>
</head>
<body>
<app-root>
<h1>About Us</h1>
<p>We are a company that builds great software.</p>
</app-root>
</body>
</html>
The user sees this content immediately no blank screen, no loading spinner.
Step 4: Browser Loads JavaScript and Hydrates
Meanwhile (or after), the browser loads your AngularDart JavaScript bundle. Instead of creating all DOM elements from scratch (which would duplicate work already done by the server), it hydrates the existing HTML:
- It detects that the page was server-rendered (
ng-server-context="ssr"attribute) - It scans the existing DOM for special markers (
data-ng-id) placed during SSR - It reuses those existing elements instead of creating new ones
- It attaches event listeners and binds data to the reused elements
The result: content appears instantly (from SSR), then becomes interactive (via hydration). No flash of unstyled content, no duplicate rendering.
Visual Summary
Time ->
-----------------------------------------------►
Server Side: Client Side:
+--------------+ +--------------------------+
| Receive | | Receive HTML |
| Request | | (content visible!) |
+--------------+ +--------------------------+
| Render to | | Download JS bundle |
| HTML string |-------------> | (while content shows) |
+--------------+ +--------------------------+
| Send full |<------------- | Parse & execute JS |
| HTML | HTTP | |
+--------------+ +--------------------------+
| Hydrate: attach events |
| to existing DOM |
+--------------------------+
User sees content immediately -> App becomes fully interactive
Architecture Overview
AngularDart's SSR implementation is built on several key abstractions that work together seamlessly.
The RenderNode Abstraction
The core innovation in AngularDart's SSR architecture is the RenderNode abstraction. This is what makes it possible to use the same component code for both server and client rendering.
+------------------+
| RenderNode | <- Abstract interface
| (interface) |
+--------+---------+
|
+------------+------------+
| |
+----------v----------+ +----------v----------+
| BrowserRenderNode | | ServerRenderNode |
| | | |
| Wraps web.Element | | Builds HTML string |
| Uses real DOM | | Uses StringBuffer |
| | | |
| appendChild(el) | | appendChild(html) |
| setProperty() | | setProperty() |
| setText() | | setText() |
+---------------------+ +--------------------+
The template compiler generates code that uses RenderNode operations. At runtime:
- On the client,
BrowserRenderNodewraps real DOM elements (web.Element) - On the server,
ServerRenderNodebuilds an HTML string using Dart'sStringBuffer
This means your component templates are compiled to the same code regardless of where they run. The difference is purely in the runtime implementation.
The RenderFactory
The RenderFactory is a singleton that creates RenderNode instances. It switches between browser and server modes:
// Enable server mode (called by platformServer)
renderFactory.useServerMode();
// Create elements this will use ServerRenderNode on the server,
// BrowserRenderNode on the client
final node = renderFactory.createElement('div', parent);
node.setText('Hello World');
// Restore browser mode after rendering
renderFactory.useBrowserMode();
Hydration Engine
The hydration engine is what makes SSR truly efficient. Instead of throwing away the server-rendered HTML and rebuilding everything from scratch on the client, it:
- Marks every DOM element with a unique
data-ng-idattribute during SSR - On the client, scans the existing DOM and builds a lookup map of IDs to elements
- When the component tree is built, reuses existing elements instead of creating new ones
- Attaches event listeners and data bindings to the reused elements
This eliminates the "double render" problem that plagued early SSR implementations (like AngularJS's ngServerSide).
Creating a Project with SSR
The easiest way to create an AngularDart project with SSR support is using the CLI. There are three SSR-related flags:
| Flag | What it creates |
|---|---|
--ssr |
Minimal SSR scaffold: server entry point, HTTP server, hydration detection. No routing. |
--hybrid |
Full SSR + routing + RenderMode control + TransferState, with components decoupled in lib/. |
--ssr --seo |
SSR + routing + SEO (angulardart_seo), with components in lib/. |
Minimal SSR project
ngdart new my_ssr_app --ssr
cd my_ssr_app
dart pub get
The --ssr flag:
- Adds
angulardart_serverto your dependencies - Creates a server entry point (
web/main.server.dart) - Creates an HTTP server (
bin/server.dart) - Writes a hydration-aware
web/main.dart(the root component is inline in this minimal scaffold) - Creates the conditional platform-DOM files (
lib/platform_dom.dart,lib/platform_dom_browser.dart,lib/platform_dom_vm.dart) - Configures
build.yamlfor dual compilation (client + server)
Hybrid project (routing + RenderMode)
For projects that need routing and per-component render mode control, use the --hybrid flag:
ngdart new my_hybrid_app --hybrid
cd my_hybrid_app
dart pub get
The --hybrid flag creates components in lib/ (app_component.dart, home_component.dart, about_component.dart, dashboard_component.dart) with different RenderMode settings (server, client, automatic) plus a data_service.dart that demonstrates TransferState. --hybrid implies SSR, so it cannot be combined with --ssr.
SSR + SEO project
ngdart new my_ssr_seo_app --ssr --seo
cd my_ssr_seo_app
dart pub get
This combines SSR with angulardart_seo (dynamic meta tags, Open Graph, Twitter Cards) and angulardart_prerender for static prerendering. See the SEO Guide for details.
Adding SSR to an Existing Project
You can also add SSR support to an existing AngularDart project:
cd my_existing_app
ngdart add ssr # or: ngdart add hybrid
dart pub get
These commands validate your project, modify pubspec.yaml, build.yaml, and index.html, create web/main.server.dart, bin/server.dart, and the conditional platform import files. They are idempotent safe to run multiple times.
Project Structure
Standard SSR Project (--ssr)
A project created with --ssr has this structure:
my_ssr_app/
├── bin/
│ └── server.dart # HTTP server (dart:io + angulardart_server)
├── lib/
│ ├── platform_dom.dart # Conditional export (browser | VM)
│ ├── platform_dom_browser.dart # Re-exports dart:html on the web
│ └── platform_dom_vm.dart # No-op stubs for the Dart VM
├── web/
│ ├── index.html # HTML shell with ng-client-context="csr"
│ ├── app_component.html # Root component template
│ ├── main.dart # Client entry point (with hydration detection)
│ ├── main.server.dart # Server entry point (exports appComponentFactory)
│ ├── main.template.dart # Generated by build_runner + generate-stubs
│ └── main.server.template.dart # Generated server bundle
├── pubspec.yaml # Dependencies include angulardart_server
├── build.yaml # Two entrypoints: web/main.dart + web/main.server.dart
└── analysis_options.yaml
Hybrid Project Structure (--hybrid)
A project created with --hybrid has components in lib/ with per-component render modes:
my_hybrid_app/
├── bin/
│ └── server.dart # HTTP server (dart:io + angulardart_server)
├── lib/
│ ├── platform_dom.dart # Conditional export (browser | VM)
│ ├── platform_dom_browser.dart # Browser-side DOM APIs
│ ├── platform_dom_vm.dart # VM-side stubs for AOT compilation
│ ├── app_component.dart # Root component with routing
│ ├── home_component.dart # RenderMode.server SEO-critical content
│ ├── about_component.dart # RenderMode.automatic (default)
│ ├── dashboard_component.dart # RenderMode.client user-specific data
│ └── data_service.dart # @Injectable() with TransferState usage
├── web/
│ ├── index.html # HTML shell with ng-client-context="csr" and <base href="/">
│ ├── main.dart # Client entry point (with hydration detection)
│ ├── main.server.dart # Server entry point (exports appComponentFactory + appInjector)
│ └── styles.css # Global styles
├── pubspec.yaml # Dependencies include angulardart_server, angulardart_router
└── build.yaml # Two entrypoints: web/main.dart + web/main.server.dart
Key Files Explained
| File | Purpose |
|---|---|
bin/server.dart |
The HTTP server that handles requests and renders AngularDart components to HTML |
web/main.server.dart |
Server entry point exports the component factory (and injector, for routing) |
web/main.dart |
Client entry point detects if page was SSR'd and hydrates, or runs normally |
pubspec.yaml |
Contains angulardart_server >=1.2.0 <2.0.0 dependency |
lib/platform_dom*.dart |
Conditional browser/VM DOM APIs required for AOT compilation on both platforms |
Example: Complete SSR Project
A complete working example demonstrating all SSR features (hybrid rendering, TransferState, routing, reactive forms) is available in the repository at angular/examples/ssr_full/. It includes:
- Home component (
RenderMode.server) withTransferState.set()for SEO-critical content - About component (
RenderMode.automatic) with encapsulated styles - Contact form (reactive forms via
angulardart_forms) - Dashboard component (
RenderMode.client) with interactive counter
Understanding Each File
The Server Entry Point (web/main.server.dart)
For a minimal --ssr project:
import 'package:angulardart/angulardart.dart';
// ignore: uri_has_not_been_generated
import 'main.template.dart' as ng;
/// Returns the root component factory for server-side rendering.
ComponentFactory<Object> get appComponentFactory =>
ng.AppComponentNgFactory;
For a --hybrid project with routing, it also exports the application injector:
import 'package:angulardart/angulardart.dart';
import 'package:angulardart_router/angulardart_router.dart';
import 'package:angulardart_server/angulardart_server.dart';
// ignore: uri_has_not_been_generated
import 'package:my_app/app_component.template.dart' as app;
// ignore: uri_has_not_been_generated
import 'main.server.template.dart' as ng;
ComponentFactory<Object> get appComponentFactory =>
app.AppComponentNgFactory;
/// Application injector (routing) for server-side rendering.
@GenerateInjector([
routerProviders,
ClassProvider(PlatformLocation, useClass: ServerPlatformLocation),
ValueProvider.forToken(appBaseHref, '/'),
])
final InjectorFactory appInjector = ng.appInjector$Injector;
The // ignore: uri_has_not_been_generated comments suppress analyzer warnings because the .template.dart files are generated by build_runner (and copied into the source tree by ngdart generate-stubs) they don't exist in version control.
The HTTP Server (bin/server.dart)
import 'dart:async';
import 'dart:io';
import 'package:angulardart_server/angulardart_server.dart';
// ignore: uri_has_not_been_generated
import '../web/main.server.dart' as ng;
Future<void> main() async {
final server = platformServer();
await HttpServer.bind('localhost', 4000).then((httpServer) {
print('AngularDart SSR server running on http://localhost:4000');
httpServer.listen((request) async {
final path = request.uri.path;
// Serve static assets (JS, CSS, images) from the build output.
if (_isStaticAsset(path)) {
await _serveStatic(request);
return;
}
try {
final html = await server.renderApplication(
ng.appComponentFactory,
url: request.uri.toString(),
);
request.response
..headers.contentType = ContentType.html
..write(html)
..close();
} catch (e, st) {
print('Render error: $e');
print(st);
request.response
..statusCode = HttpStatus.internalServerError
..write('<h1>Server Error</h1>')
..close();
}
});
});
}
This is a standard Dart HTTP server using dart:io. Here's what happens for each request:
platformServer()creates the SSR platform instance (singleton)server.renderApplication(...)renders your AngularDart component tree to a complete HTML string, including<html>,<head>, and<body>tags- The
urlparameter tells AngularDart what URL was requested, so the router can match routes correctly (important for server-side routing) - Static assets (compiled
main.dart.js, CSS, images) are served directly from disk; everything else is server-rendered
Note about routing: for a project that uses the router, you must also pass a
parentInjectorthat providesappBaseHref(see Server-Side Routing).
The Client Entry Point (web/main.dart)
import 'package:angulardart/angulardart.dart';
import 'package:angulardart_server/angulardart_server.dart';
import 'package:my_app/platform_dom.dart' as platform_dom;
// ignore: uri_has_not_been_generated
import 'main.template.dart' as ng;
void main() async {
// Detect if the page was server-rendered
final isServerRendered =
(platform_dom.window as dynamic).document.documentElement?.getAttribute('ng-server-context') == 'ssr';
if (isServerRendered) {
// Hydrate: reuse existing DOM from SSR
await hydrateApplication(ng.AppComponentNgFactory);
} else {
// Normal client-side rendering
runApp(ng.AppComponentNgFactory);
}
}
This is the most critical file for understanding how hydration works. The key logic:
-
Detection: It checks if the
<html>element hasng-server-context="ssr". This attribute is automatically added byrenderApplication()in the server HTML wrapper. -
Hydration path (
hydrateApplication): If the page was SSR'd, AngularDart reuses the existing DOM elements instead of creating new ones. Event listeners and data bindings are attached to the pre-existing elements. -
Normal rendering path (
runApp): If no SSR was detected (e.g., direct access toindex.html, or a client-only request), it renders normally as a standard SPA.
Key insight: The same
main.dartfile works for both SSR and non-SSR scenarios. Theplatform_dom.dartconditional export (export 'platform_dom_browser.dart' if (dart.library.io) 'platform_dom_vm.dart') re-exportsdart:htmlon the web and no-op stubs on the VM, so the file compiles on both platforms. No conditional compilation needed just runtime detection.
Building and Running
This is the build flow, and it differs from a plain CSR project in two important ways.
The Build Commands
dart pub get
dart run build_runner clean
dart run build_runner build web --release
ngdart generate-stubs
dart bin/server.dart
Then open http://localhost:4000 in your browser.
Why --release?
build_runner build in dev mode compiles with the Dart Dev Compiler (DDC). The resulting main.dart.js is only a bootstrap that loads the actual modules (require.js, stack_trace_mapper.dart.js, ...) from /packages/.... Those are only served by build_runner serve, so a standalone bin/server.dart would return 404 for them.
--release compiles with dart2js instead, producing a self-contained main.dart.js that the standalone server can serve directly. Always use --release for the SSR build.
Why ngdart generate-stubs?
bin/server.dart runs on the Dart VM (native Dart, not JavaScript). It imports web/main.server.dart, which in turn imports the generated .template.dart files. build_runner writes those files under .dart_tool/build/generated/<package>/, but the Dart VM resolves package: imports to lib/ and relative imports to web/.
ngdart generate-stubs copies the generated .template.dart (and .css.shim.dart) files from .dart_tool/build/generated/<package>/ into your web/ and lib/ directories, so the VM can find them. It also creates safe stub templates and strips browser-only imports. Run it after every build.
Render Modes
AngularDart supports three render modes that you can specify per component:
enum RenderMode {
/// Server-side rendering + client hydration
server,
/// Client-side only (not rendered on the server)
client,
/// Automatic: SSR on server, CSR on client
automatic,
}
Using renderMode in Components
You specify the render mode as a parameter to the @Component decorator:
// This component will always be rendered on the server
@Component(
selector: 'hero-section',
template: '<h1>Welcome</h1>',
renderMode: RenderMode.server,
)
class HeroSection {}
// This component is client-only (not rendered on server at all)
@Component(
selector: 'user-dashboard',
template: '<p>User-specific content</p>',
renderMode: RenderMode.client,
)
class UserDashboard {}
// Default behavior automatic detection
@Component(
selector: 'footer',
template: '<p>© 2025 My App</p>',
// renderMode defaults to RenderMode.automatic
)
class Footer {}
When to Use Each Mode
| Mode | Best For | Example |
|---|---|---|
RenderMode.server |
SEO-critical content, marketing pages, blog posts | Home page, About page, Product pages |
RenderMode.client |
User-specific/dynamic content, authenticated areas | Dashboard, user profile, shopping cart |
RenderMode.automatic (default) |
Shared components that benefit from SSR when available | Navigation bar, footer, common widgets |
Server-Side Behavior by Render Mode
When a component has renderMode: RenderMode.client, the server doesn't render its content. Instead, it outputs an empty placeholder tag:
<!-- Server renders this for client-only components -->
<user-dashboard></user-dashboard>
The browser then fills in the actual content via client-side rendering during hydration or normal app startup. This is useful for:
- Performance: Avoid rendering expensive components on the server
- Correctness: Some features (like
windowaccess, event listeners) only work on the client - Security: User-specific data should never be shared in server-rendered HTML
Hybrid Rendering in Detail
Hybrid Rendering means mixing different render modes within the same application. Some pages or components are rendered on the server, while others are rendered exclusively on the client all in a single request/response cycle.
Example: A Page with Mixed Render Modes
Imagine an e-commerce site where:
- The product listing page is SSR'd (for SEO)
- The user's shopping cart is CSR-only (personalized, dynamic data)
- The navigation bar uses automatic mode (works in both contexts)
@Component(
selector: 'product-page',
template: '<nav-bar></nav-bar>'
'<h1>{{productName}}</h1>'
'<p>{{productDescription}}</p>'
'<shopping-cart></shopping-cart>',
directives: [NavBar, ShoppingCart],
)
class ProductPage {}
@Component(
selector: 'nav-bar',
template: '<a href="/">Home</a>',
renderMode: RenderMode.automatic, // SSR when available
)
class NavBar {}
@Component(
selector: 'shopping-cart',
template: '<h2>Your Cart</h2>'
'<ul><li *ngFor="let item of items">'
'{{item.name}} x{{item.quantity}} - ${{item.price}}</li></ul>',
renderMode: RenderMode.client, // Never SSR'd user-specific data
)
class ShoppingCart {}
When the server renders ProductPage:
<nav-bar>is rendered as HTML (because it's automatic/SSR-capable)<shopping-cart>outputs an empty placeholder tag (<shopping-cart></shopping-cart>)- The rest of the product content is SSR'd
On the client:
- The nav bar is hydrated from existing DOM
- The shopping cart is rendered fresh by the client (since it wasn't in server HTML)
- Everything becomes interactive through hydration + normal binding
Why Hybrid Rendering Matters
Without hybrid rendering, you face a trade-off:
- All SSR: Fast SEO, but wasted effort rendering user-specific content that changes immediately on the client
- All CSR: Good for dynamic apps, but poor SEO and slow initial load
Hybrid rendering lets you choose the right approach for each part of your application.
TransferState: Sharing Data Between Server and Client
A common challenge with SSR is sharing data fetched on the server with the client. Without TransferState, you'd make the same API call twice once on the server (during rendering) and again on the client (after hydration).
TransferState solves this by serializing data into a <script> tag in the HTML, which the client reads after hydration.
Server-Side: Storing Data
import 'package:angulardart_server/angulardart_server.dart';
// Fetch data on the server during rendering
final users = await http.get('https://api.example.com/users').then(
(response) => jsonDecode(response.body) as List,
);
// Store it in TransferState this will be serialized into the HTML
TransferState.set<List>('users', users);
// Now render the component it can access this data via TransferState.get()
final html = await server.renderComponent(AppComponentNgFactory);
Client-Side: Reading Data
import 'package:angulardart_server/angulardart_server.dart';
void main() async {
final isServerRendered =
(platform_dom.window as dynamic).document.documentElement?.getAttribute('ng-server-context') == 'ssr';
if (isServerRendered) {
await hydrateApplication(AppComponentNgFactory);
// After hydration, read the transferred state
final users = TransferState.get<List>('users');
print('Received ${users?.length} users from server!');
} else {
runApp(AppComponentNgFactory);
}
}
How It Works Under the Hood
On the server, TransferState.toScript() generates a <script> tag:
<script id="ng-transfer-state" type="application/json">
{"ng-transfer-state:users":[{"name":"Alice"},{"name":"Bob"}]}
</script>
This script is automatically injected into the HTML by renderApplication(). On the client, TransferState.fromHtml() (called automatically by hydrateApplication) reads this script tag and populates the in-memory state map.
What Types Are Supported?
TransferState supports any data that can be serialized to JSON:
String,int,double,boolList<T>where T is serializableMap<String, dynamic>with serializable values- Nested combinations of the above
Non-serializable objects are serialized using their JSON encoding (falling back to a placeholder string).
Server-Side Routing
To use angulardart_router together with SSR, the router must be able to resolve the requested URL on the server (where there is no window/document).
Client side
Your root component injects Router, declares its routes, and renders a <router-outlet>:
import 'package:angulardart/angulardart.dart';
import 'package:angulardart_router/angulardart_router.dart';
// ignore: uri_has_not_been_generated
import 'home_component.template.dart' as home;
// ignore: uri_has_not_been_generated
import 'about_component.template.dart' as about;
@Component(
selector: 'app-root',
templateUrl: 'app_component.html',
directives: [routerDirectives],
)
class AppComponent implements OnInit {
final Router _router;
List<RouteDefinition> routes = [];
AppComponent(this._router);
@override
void ngOnInit() {
_router.onRouteActivated.listen((_) {});
routes = [
RouteDefinition(path: '/', component: home.HomeComponentNgFactory, useAsDefault: true),
RouteDefinition(path: '/about', component: about.AboutComponentNgFactory),
];
}
}
<!-- app_component.html -->
<nav>
<a [routerLink]="['/']" routerLinkActive="active">Home</a> |
<a [routerLink]="['/about']" routerLinkActive="active">About</a>
</nav>
<main><router-outlet [routes]="routes"></router-outlet></main>
And provide the router providers at the root:
@GenerateInjector([routerProviders])
final InjectorFactory appInjector = ng.appInjector$Injector;
// Pass it to bootstrap:
runApp(app.AppComponentNgFactory, createInjector: appInjector);
// ...and to hydration:
await hydrateApplication(app.AppComponentNgFactory, createInjector: appInjector);
Server side
On the server, renderApplication automatically injects a ServerPlatformLocation that reads the URL from the HTTP request. But PathLocationStrategy also needs an appBaseHref (there is no <base href> element on the VM). Provide it via the parentInjector:
// bin/server.dart
final baseHrefInjector = Injector.map({appBaseHref: '/'});
final html = await server.renderApplication(
ng.appComponentFactory,
url: request.uri.toString(),
parentInjector: ng.appInjector(baseHrefInjector),
);
And make sure web/main.server.dart exports appInjector with routerProviders (see The Server Entry Point).
Static asset fallback for routes
Because your SPA routes (/about, /products/42) don't correspond to files on disk, the server must treat any non-asset path as a route to render (which the template bin/server.dart already does). For a production deployment behind a reverse proxy, you may also need to rewrite unknown paths to the server (see Deployment Guide).
Building a Custom SSR Server
While the basic server example uses dart:io's HttpServer, you can build more sophisticated servers using any Dart HTTP framework. Here's an example with shelf (Google's HTTP middleware library):
import 'dart:async';
import 'dart:io';
import 'package:angulardart_server/angulardart_server.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf_static/shelf_static.dart' as static_file_handler;
// ignore: uri_has_not_been_generated
import '../web/main.server.dart' as ng;
Future<void> main() async {
final server = platformServer();
// Serve static assets (JS, CSS, images) from the build output directory.
final staticHandler = static_file_handler.createStaticHandler('build/web');
await HttpServer.bind('localhost', 4000).then((httpServer) {
httpServer.listen((request) async {
final path = request.uri.path;
// Let static files pass through (JS, CSS, images).
if (_isStaticAsset(path)) {
final response = await staticHandler(request.uri.path);
if (response != null) {
request.response.statusCode = response.statusCode;
await request.response.addStream(response.read());
await request.response.close();
return;
}
}
// SSR for all other routes.
try {
final html = await server.renderApplication(
ng.appComponentFactory,
url: request.uri.toString(),
);
request.response
..headers.contentType = ContentType.html
..write(html)
..close();
} catch (e) {
request.response
..statusCode = HttpStatus.internalServerError
..write('<h1>Server Error</h1>')
..close();
}
});
});
}
bool _isStaticAsset(String path) {
return path.endsWith('.js') ||
path.endsWith('.css') ||
path.endsWith('.png') ||
path.endsWith('.jpg') ||
path.endsWith('.ico') ||
path.contains('/assets/');
}
This server:
- Serves static assets (JS, CSS, images) directly from the file system
- Renders AngularDart components via SSR for all other routes (including SPA navigation paths like
/about,/products/42) - Handles errors gracefully with a fallback error page
Note: the exact static path depends on your build configuration. The compiled client bundle is written under
.dart_tool/build/generated/<package>/web/(orbuild/webif you usebuild_runner build -o build:web). Adjust thestatic_file_handlerdirectory accordingly.
Advanced: Per-Component Render Mode Configuration
You can fine-tune which components are rendered on the server and which are client-only. This is useful when you have:
- SEO-critical pages (home, about, blog) that should always be SSR'd
- User-specific dashboards that contain private data and shouldn't be SSR'd
- Heavy interactive widgets that don't benefit from server rendering
Example: E-commerce Site with Mixed Rendering
// SEO-critical rendered on server for search engines
@Component(
selector: 'product-listing',
template: '<h1>Products</h1>'
'<div *ngFor="let product of products">'
'<a href="/products/{{product.id}}">{{product.name}}</a>'
'<p>{{product.description}}</p></div>',
renderMode: RenderMode.server,
)
class ProductListing {}
// Client-only user-specific shopping cart
@Component(
selector: 'shopping-cart',
template: '<h2>Your Cart</h2>'
'<ul><li *ngFor="let item of items">'
'{{item.name}} x{{item.quantity}} - ${{item.price}}</li></ul>',
renderMode: RenderMode.client,
)
class ShoppingCart {}
// Automatic works in both contexts
@Component(
selector: 'site-header',
template: '<nav>'
'<a href="/">Home</a>'
'<a href="/products">Products</a>'
'<a href="/cart">Cart ({{cartCount}})</a></nav>',
)
class SiteHeader {}
// Root component orchestrating everything
@Component(
selector: 'app-root',
template: '<site-header></site-header>'
'<router-outlet [routes]="routes"></router-outlet>',
directives: [SiteHeader, ProductListing, ShoppingCart],
)
class AppComponent {}
When a user visits /products:
- Server renders
<product-listing>with full HTML (SEO-friendly!) - Server outputs
<shopping-cart></shopping-cart>as an empty placeholder - Server renders
<site-header>with navigation links - Client hydrates the header and product listing from existing DOM
- Client renders the shopping cart fresh (since it wasn't in server HTML)
Prerendering vs Real-Time SSR
AngularDart offers two approaches for generating SEO-friendly HTML: SSR and Prerendering. Understanding the difference helps you choose the right one.
| Aspect | SSR (Real-Time) | Prerendering (Static) |
|---|---|---|
| When HTML is generated | On each request (or cache per route) | At build time, before deployment |
| Server required? | Yes needs a running Dart server | No static files only |
| Dynamic content | Can include real-time data | Static snapshot at build time |
| Performance | Slightly slower (server rendering overhead) | Instant (served from CDN/static host) |
| Best for | Content that changes frequently, personalized pages | Marketing sites, blogs, documentation |
| Deployment complexity | Needs a server process | Just upload static files to any host |
When to Use SSR
- Your content changes frequently (e.g., news site, live dashboard)
- You need real-time data in your HTML
- You have many routes and don't want long build times
- You're building an application with both public and authenticated pages
When to Use Prerendering
- Your content is mostly static (marketing sites, documentation)
- You want the simplest possible deployment (static hosting)
- You need maximum performance (CDN caching)
- You have a known set of routes that don't change often
You can also combine both: prerender your public pages for speed, and use SSR for dynamic routes behind authentication. See the Prerendering Guide for more details.
Common Patterns and Best Practices
1. Use RenderMode.automatic as Your Default
Unless you have a specific reason to force server or client rendering, use the default automatic mode:
@Component(
selector: 'my-component',
template: '<p>Hello</p>',
// renderMode defaults to RenderMode.automatic no need to specify!
)
class MyComponent {}
This gives you SSR benefits when running on the server, and falls back gracefully to CSR if needed.
2. Use RenderMode.client for Browser-Only Features
If your component accesses browser APIs (window, document, localStorage), use client mode:
@Component(
selector: 'browser-info',
template: '<p>Window width: {{width}}px</p>',
renderMode: RenderMode.client, // Can't access window on server!
)
class BrowserInfoComponent implements OnInit {
int width = 0;
@override
void ngOnInit() {
width = web.window.innerWidth;
}
}
3. Use TransferState to Avoid Double API Calls
If you fetch data in ngOnInit() and your component is SSR'd, the same data will be fetched again on the client:
// Without TransferState data fetched twice!
@override
void ngOnInit() {
_data = await _api.fetchData(); // Once on server, once on client
}
With TransferState:
// With TransferState data fetched only on server!
@override
void ngOnInit() async {
final cached = TransferState.get<List>('my-data');
if (cached != null) {
_data = cached; // Use transferred state from server
} else {
_data = await _api.fetchData(); // Fetch on client if no SSR
TransferState.set('my-data', _data); // Store for future use
}
}
4. Handle Server-Side Routing Correctly
When using renderApplication(), always pass the requested URL so the router can match routes:
// Correct router knows which route to match
final html = await server.renderApplication(
ng.appComponentFactory,
url: request.uri.toString(), // e.g., "/products/42"
);
// Incorrect router defaults to "/" for all requests
final html = await server.renderComponent(ng.appComponentFactory);
5. Error Handling on the Server
Always wrap SSR rendering in try/catch blocks and provide fallback content:
httpServer.listen((request) async {
try {
final html = await server.renderApplication(
ng.appComponentFactory,
url: request.uri.toString(),
);
request.response
..statusCode = HttpStatus.ok
..write(html)
..close();
} catch (e, st) {
print('SSR error on ${request.uri.path}: $e');
print(st);
// Fallback: serve the SPA shell so client-side rendering can recover
final indexHtml = await File('web/index.html').readAsString();
request.response
..statusCode = HttpStatus.ok
..write(indexHtml)
..close();
}
});
6. Separate Server and Client Entry Points
Keep your server entry point (web/main.server.dart) separate from your client entry point (web/main.dart). This allows:
- The server to run on the Dart VM while the client runs compiled JavaScript
- Clear separation of concerns
- Easier debugging and maintenance
7. Use renderComponent() for Simple Cases, renderApplication() for Full HTML
renderComponent(): Returns only the component's HTML output. Use this when you want full control over the HTML wrapper (e.g., injecting custom<head>content).renderApplication(): Returns a complete HTML document with<!DOCTYPE html>,<html>,<head>, and<body>tags. Use this for quick setup or when you don't need custom HTML structure.
8. Always Rebuild with --release and generate-stubs
After any change to your components or templates:
dart run build_runner build web --release
ngdart generate-stubs
The .template.dart files are generated by build_runner, then copied into the source tree by ngdart generate-stubs so dart bin/server.dart can resolve them on the VM.
Troubleshooting
Component Renders Empty on Server
Symptom: The server returns HTML but your component's content is missing (just the selector tag).
Causes and solutions:
renderMode: RenderMode.clientThis component is intentionally not rendered on the server. Change toRenderMode.serverorRenderMode.automatic.- Missing directive/component in
directiveslist Ensure all child components are listed in the parent's@Component(directives: [...]). - Build not up to date Run
dart run build_runner build web --release && ngdart generate-stubsagain after making changes.
Hydration Not Working (Content Flickers)
Symptom: The page shows server-rendered content, then briefly flashes and re-renders from scratch.
Causes and solutions:
- Missing
ng-server-context="ssr"detection Ensure yourmain.dartchecks for this attribute before callinghydrateApplication(). - Server not adding the attribute Use
renderApplication()instead of manually building HTML, or addng-server-context="ssr"to your<html>tag yourself. - Multiple AngularDart apps bootstrapped Ensure only one app is running per page.
TransferState Data Not Available on Client
Symptom: TransferState.get() returns null even though you set data on the server.
Causes and solutions:
- Calling before hydration completes
hydrateApplication()callsTransferState.fromHtml()internally. Make sure you readTransferStateafterhydrateApplication()has finished (useawait). - Data not serializable Ensure your data can be serialized to JSON (no functions, circular references).
- Key mismatch The key used in
set()andget()must match exactly (including the prefix).
Server Returns 500 Error
Symptom: All requests return a 500 Internal Server Error.
Causes and solutions:
- Check server logs The error message is printed to stdout. Look for stack traces.
- Port already in use Change the port:
HttpServer.bind('localhost', 4001). - Missing template files Ensure you've run
dart run build_runner build web --releaseandngdart generate-stubs, and that the.template.dartfiles exist inweb/andlib/.
Build Runner Fails with "URI has not been generated"
Symptom: Compilation fails with errors about main.template.dart not being found.
Solution: The .template.dart files are generated by the AngularDart compiler and don't exist in source control. Run build_runner first:
dart run build_runner build web --release
ngdart generate-stubs
If you still get analyzer warnings before the first build, the generated // ignore: uri_has_not_been_generated comments suppress them by design.
SSR Works Locally but Not on Production Server
Symptom: Everything works with dart bin/server.dart locally, but fails after deployment.
Causes and solutions:
- Missing build output Ensure you run
dart run build_runner build web --release && ngdart generate-stubsas part of your deployment pipeline (not just locally). - Wrong working directory The server uses relative paths (
../web/main.server.dart). Rundart bin/server.dartfrom the project root. - Memory limits SSR can be memory-intensive for large pages. Consider increasing your server's memory allocation or implementing response caching.
Next Steps
- Prerendering Learn about static prerendering as an alternative to real-time SSR
- SEO Guide Optimize meta tags, Open Graph, and structured data including server-aware SeoService with TransferState integration
- Deployment Guide Deploy your AngularDart application with SSR to production
See Also
- Angular Universal The Angular (TypeScript) equivalent of AngularDart's SSR implementation. AngularDart's API is inspired by Angular Universal.
- Hydration in Angular 17+ Angular's client-side hydration feature, which inspired AngularDart's approach.