Repository: QutEcoacoustics/workbench-client
Pull request #2292: Migrate to server routing
Author: login: hudson-newey
Labels: name: architecture
description: Architectural changes to the software
Created: 2025-05-15T01:56:22Z | Merged: 2025-05-19T01:14:22Z

# Migrate to server routing

This enables [hybrid rendering](https://angular.dev/guide/hybrid-rendering) and allows us to specify what rendering strategy we should use on a per-page basis through the `PageInfo`.

---

We might want to expose this through the `StrongRoute` instead of the `PageInfo` because `ServerRoute`'s support a lot of other cool features such as specific headers for certain routes, status codes, etc....

https://angular.dev/guide/hybrid-rendering#setting-headers-and-status-codes

Additionally, putting all of this information on the `PageInfo` leaks server routing information to the client which we might not want.

## Changes

- Migrates to the app server router.
- Migrate the home page to use pre-rendering (ssg) and defer the loading of "example project" cards to the client.
	- this was done more or less to test that the server routing / `renderMode` was working correctly. Let me know if you want this reverted, but I think it's a nice improvement since it improves the load times, and the "example project" cards are typically off screen on first load, so by the time the user scrolls to them, they would have loaded in by csr.
- Replaces `REQUEST` and `RESPONSE` injection tokens with Angular native SSR tokens.
- Do not provide `APP_CONFIG` provider twice (we were providing it through the express provides and the `config.service`.
- Fixes console errors being thrown when page title route models could not be accessed due to un-authenticated sessions.
- Do not show "bad" website status warnings if the website status has not been fetched.

## Issues

Fixes: #2290

## Final Checklist

- [ ] Assign reviewers if you have permission
- [ ] Assign labels if you have permission
- [ ] Link issues related to PR
- [ ] Ensure project linter is not producing any warnings (`npm run lint`)
- [ ] Ensure build is passing on all browsers (`npm run test:all`)
- [ ] Ensure CI build is passing
- [ ] Ensure docker container is passing ([docs](https://github.com/QutEcoacoustics/workbench-client#docker))



Diff:
diff --git a/angular.json b/angular.json
index da21ee283b..ed95f7639c 100644
--- a/angular.json
+++ b/angular.json
@@ -55,10 +55,10 @@
             ],
             "browser": "src/main.ts",
             "server": "src/main.server.ts",
-            "prerender": false,
             "ssr": {
               "entry": "src/server.ts"
-            }
+            },
+            "outputMode": "server"
           },
           "configurations": {
             "production": {
diff --git a/package.json b/package.json
index 7f654fc40f..e4438f996d 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
     "build": "ng build --configuration production",
     "lint": "ng lint",
     "ng": "node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng",
-    "start": "ng serve --ssl --host 0.0.0.0",
+    "start": "ng serve --ssl --host development.ecosounds.org",
     "stats": "ng build --configuration development --stats-json && webpack-bundle-analyzer dist/workbench-client/browser/stats.json",
     "test": "npm run test:chrome",
     "test:chrome": "ng test --code-coverage --browsers Chrome",
diff --git a/src/app/app.config.server.ts b/src/app/app.config.server.ts
index a896d65f50..34dd3ad54e 100644
--- a/src/app/app.config.server.ts
+++ b/src/app/app.config.server.ts
@@ -10,7 +10,9 @@ import { DeviceDetectorService } from "ngx-device-detector";
 import { UniversalDeviceDetectorService } from "@services/universal-device-detector/universal-device-detector.service";
 import { providerTimeoutInterceptor } from "@services/timeout/provide-timeout";
 import { environment } from "src/environments/environment";
+import { provideServerRouting } from "@angular/ssr";
 import { appConfig } from "./app.config";
+import { serverRoutes } from "./app.routes";
 
 // we disable caching on the server to prevent potentially serving stale data
 // and data that requires authorization to unauthenticated users
@@ -22,6 +24,7 @@ export const serverCacheConfig = {
 const serverConfig: ApplicationConfig = {
   providers: [
     provideServerRendering(),
+    provideServerRouting(serverRoutes),
 
     {
       provide: DeviceDetectorService,
diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index 4a192b7483..c68e116413 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -16,7 +16,7 @@ import { RehydrationInterceptorService } from "@services/rehydration/rehydration
 import { HTTP_INTERCEPTORS } from "@angular/common/http";
 import { provideBawApi } from "@baw-api/provide-baw-api";
 import { appPageComponents } from "./app.pages";
-import { routes } from "./app.routes";
+import { clientRoutes } from "./app.routes";
 
 export const appLibraryImports = [
   ReactiveFormsModule,
@@ -28,7 +28,7 @@ export const appConfig: ApplicationConfig = {
   providers: [
     ...appPageComponents,
     provideRouter(
-      routes,
+      clientRoutes,
       /*
         Initial navigation triggers resolvers before APP_INITIALIZER which
         means that the config has not been loaded. Disabling this fixes the
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 2d2a393dec..fad42aed30 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -1,4 +1,3 @@
-import { Routes } from "@angular/router";
 import { aboutRoute } from "@components/about/about.menus";
 import { adminRoute } from "@components/admin/admin.menus";
 import { adminAnalysisJobsRoute } from "@components/admin/analysis-jobs/analysis-jobs.menus";
@@ -37,71 +36,65 @@ import { statisticsRoute } from "@components/statistics/statistics.menus";
 import { visualizeRoute } from "@components/visualize/visualize.routes";
 import { websiteStatusRoute } from "@components/website-status/website-status.routes";
 import {
-  getRouteConfigForIndexed,
-  getRouteConfigForPage,
+  compileAndSplitRoutes,
+  splitIndexedStrongRoutes,
 } from "@helpers/page/pageRouting";
+import { StrongRoute } from "@interfaces/strongRoute";
 
-const staticRoutes = [
-  {
-    path: "research",
-    children: [
-      { path: "about", redirectTo: "https://research.ecosounds.org/" },
-      {
-        path: "articles",
-        redirectTo: "https://research.ecosounds.org/articles.html",
-      },
-      {
-        path: "resources",
-        redirectTo: "https://research.ecosounds.org/resources.html",
-      },
-      {
-        path: "people",
-        redirectTo: "https://research.ecosounds.org/people/people.html",
-      },
-      {
-        path: "publications",
-        redirectTo:
-          "https://research.ecosounds.org/publications/publications.html",
-      },
-    ],
-  },
-] as const satisfies Routes;
+const researchRoutes = StrongRoute.newRoot().add("research");
+researchRoutes.add("about", null, {
+  redirectTo: "https://research.ecosounds.org/",
+});
+researchRoutes.add("about", null, {
+  redirectTo: "https://research.ecosounds.org/articles.html",
+});
+researchRoutes.add("resources", null, {
+  redirectTo: "https://research.ecosounds.org/resources.html",
+});
+researchRoutes.add("people", null, {
+  redirectTo: "https://research.ecosounds.org/people/people.html",
+});
+researchRoutes.add("publications", null, {
+  redirectTo: "https://research.ecosounds.org/publications/publications.html",
+});
 
-export const routes: Routes = [
-  aboutRoute.compileRoutes(getRouteConfigForPage),
-  adminRoute.compileRoutes(getRouteConfigForPage),
-  analysesRoute.compileRoutes(getRouteConfigForPage),
-  scriptsRoute.compileRoutes(getRouteConfigForPage),
-  getRouteConfigForIndexed(audioRecordingsRoutes),
-  citSciRoute.compileRoutes(getRouteConfigForPage),
-  dataRequestRoute.compileRoutes(getRouteConfigForPage),
-  harvestsRoute.compileRoutes(getRouteConfigForPage),
-  getRouteConfigForIndexed(reportsRoute),
-  getRouteConfigForIndexed(verificationRoute),
-  annotationsImportRoute.compileRoutes(getRouteConfigForPage),
-  libraryRoute.compileRoutes(getRouteConfigForPage),
-  listenRoute.compileRoutes(getRouteConfigForPage),
-  myAccountRoute.compileRoutes(getRouteConfigForPage),
-  theirProfileRoute.compileRoutes(getRouteConfigForPage),
-  projectsRoute.compileRoutes(getRouteConfigForPage),
-  regionsRoute.compileRoutes(getRouteConfigForPage),
-  shallowRegionsRoute.compileRoutes(getRouteConfigForPage),
-  reportProblemsRoute.compileRoutes(getRouteConfigForPage),
-  securityRoute.compileRoutes(getRouteConfigForPage),
-  sendAudioRoute.compileRoutes(getRouteConfigForPage),
-  sitesRoute.compileRoutes(getRouteConfigForPage),
-  pointsRoute.compileRoutes(getRouteConfigForPage),
-  statisticsRoute.compileRoutes(getRouteConfigForPage),
-  websiteStatusRoute.compileRoutes(getRouteConfigForPage),
-  visualizeRoute.compileRoutes(getRouteConfigForPage),
-  adminTagsRoute.compileRoutes(getRouteConfigForPage),
-  adminOrphansRoute.compileRoutes(getRouteConfigForPage),
-  adminSettingsRoute.compileRoutes(getRouteConfigForPage),
-  adminTagGroupsRoute.compileRoutes(getRouteConfigForPage),
-  adminAnalysisJobsRoute.compileRoutes(getRouteConfigForPage),
+const routes: StrongRoute[] = [
+  aboutRoute,
+  adminRoute,
+  analysesRoute,
+  scriptsRoute,
+  ...splitIndexedStrongRoutes(audioRecordingsRoutes),
+  citSciRoute,
+  dataRequestRoute,
+  harvestsRoute,
+  ...splitIndexedStrongRoutes(reportsRoute),
+  ...splitIndexedStrongRoutes(verificationRoute),
+  annotationsImportRoute,
+  libraryRoute,
+  listenRoute,
+  myAccountRoute,
+  theirProfileRoute,
+  projectsRoute,
+  regionsRoute,
+  shallowRegionsRoute,
+  reportProblemsRoute,
+  securityRoute,
+  sendAudioRoute,
+  sitesRoute,
+  pointsRoute,
+  statisticsRoute,
+  websiteStatusRoute,
+  visualizeRoute,
+  adminTagsRoute,
+  adminOrphansRoute,
+  adminSettingsRoute,
+  adminTagGroupsRoute,
+  adminAnalysisJobsRoute,
+
+  researchRoutes,
 
-  homeRoute.compileRoutes(getRouteConfigForPage),
-  pageNotFoundRoute.compileRoutes(getRouteConfigForPage),
+  homeRoute,
+  pageNotFoundRoute,
+];
 
-  ...staticRoutes,
-].flat();
+export const [clientRoutes, serverRoutes] = compileAndSplitRoutes(routes);
diff --git a/src/app/components/admin/analysis-jobs/analysis-jobs.menus.ts b/src/app/components/admin/analysis-jobs/analysis-jobs.menus.ts
index c4a322c5ad..386d5887cf 100644
--- a/src/app/components/admin/analysis-jobs/analysis-jobs.menus.ts
+++ b/src/app/components/admin/analysis-jobs/analysis-jobs.menus.ts
@@ -31,6 +31,6 @@ export const adminAnalysisJobMenuItem = menuRoute({
   predicate: isAdminPredicate,
   title: (routeData: RouterStateSnapshot): string => {
     const componentModel = routeData.root.firstChild.data;
-    return componentModel.analysisJob.model.name;
+    return componentModel.analysisJob.model?.name;
   }
 });
diff --git a/src/app/components/admin/orphan/orphans.menus.ts b/src/app/components/admin/orphan/orphans.menus.ts
index 022b258e24..e7ec951e95 100644
--- a/src/app/components/admin/orphan/orphans.menus.ts
+++ b/src/app/components/admin/orphan/orphans.menus.ts
@@ -29,7 +29,7 @@ export const adminOrphanMenuItem = menuRoute({
   predicate: isAdminPredicate,
   title: (routeData: RouterStateSnapshot): string => {
     const componentModel = routeData.root.firstChild.data;
-    const orphanSiteName = componentModel.site.model.name;
+    const orphanSiteName = componentModel.site.model?.name;
     return orphanSiteName;
   }
 });
diff --git a/src/app/components/audio-analysis/analysis-jobs.menus.ts b/src/app/components/audio-analysis/analysis-jobs.menus.ts
index 6e0fd1dce9..861ab1ba68 100644
--- a/src/app/components/audio-analysis/analysis-jobs.menus.ts
+++ b/src/app/components/audio-analysis/analysis-jobs.menus.ts
@@ -30,6 +30,6 @@ export const analysisJobMenuItem = menuRoute({
     retrieveResolvedModel(pageInfo, AnalysisJob)?.name,
   title: (routeData: RouterStateSnapshot): string => {
     const componentModel = routeData.root.firstChild.data;
-    return componentModel.analysisJob.model.name;
+    return componentModel.analysisJob.model?.name;
   },
 });
diff --git a/src/app/components/harvest/harvest.menus.ts b/src/app/components/harvest/harvest.menus.ts
index 806aff2e84..2c39be0288 100644
--- a/src/app/components/harvest/harvest.menus.ts
+++ b/src/app/components/harvest/harvest.menus.ts
@@ -43,6 +43,6 @@ export const harvestMenuItem = menuRoute({
   route: harvestRoute,
   title: (routeData: RouterStateSnapshot): string => {
     const componentModel = routeData.root.firstChild.data;
-    return componentModel.harvest.model.name;
+    return componentModel.harvest.model?.name;
   },
 });
diff --git a/src/app/components/home/home.component.spec.ts b/src/app/components/home/home.component.spec.ts
index db44ef2c8e..5bf9e64831 100644
--- a/src/app/components/home/home.component.spec.ts
+++ b/src/app/components/home/home.component.spec.ts
@@ -7,40 +7,40 @@ import { Errorable } from "@helpers/advancedTypes";
 import { Settings } from "@helpers/app-initializer/app-initializer";
 import { IProject, Project } from "@models/Project";
 import { IRegion, Region } from "@models/Region";
-import {
-  createRoutingFactory,
-  Spectator,
-  SpyObject,
-} from "@ngneat/spectator";
+import { createRoutingFactory, Spectator, SpyObject } from "@ngneat/spectator";
 import { ConfigService } from "@services/config/config.service";
 import { testApiConfig } from "@services/config/configMock.service";
 import { IconsModule } from "@shared/icons/icons.module";
-import { CardComponent } from "@shared/model-cards/card/card.component";
 import { CardsComponent } from "@shared/model-cards/cards/cards.component";
 import { generateBawApiError } from "@test/fakes/BawApiError";
 import { generateProject } from "@test/fakes/Project";
 import { generateRegion } from "@test/fakes/Region";
 import { interceptFilterApiRequest } from "@test/helpers/general";
 import { assertPageInfo } from "@test/helpers/pageRoute";
-import { MockComponent } from "ng-mocks";
-import { BehaviorSubject } from "rxjs";
+import { BehaviorSubject, of } from "rxjs";
 import { LoadingComponent } from "@shared/loading/loading.component";
 import { AsyncPipe, TitleCasePipe, UpperCasePipe } from "@angular/common";
 import { WithLoadingPipe } from "@pipes/with-loading/with-loading.pipe";
 import { provideMockConfig } from "@services/config/provide-configMock";
 import { provideMockBawApi } from "@baw-api/provide-baw-ApiMock";
+import { AudioRecordingsService } from "@baw-api/audio-recording/audio-recordings.service";
+import { AUDIO_RECORDING, PROJECT, SHALLOW_REGION } from "@baw-api/ServiceTokens";
+import { AssociationInjector } from "@models/ImplementsInjector";
+import { ASSOCIATION_INJECTOR } from "@services/association-injector/association-injector.tokens";
+import { isBawApiError } from "@helpers/custom-errors/baw-api-error";
 import { HomeComponent } from "./home.component";
 
 describe("HomeComponent", () => {
   let regionApi: SpyObject<ShallowRegionsService>;
   let projectApi: SpyObject<ProjectsService>;
   let cmsService: SpyObject<CmsService>;
+  let recordingService: SpyObject<AudioRecordingsService>;
   let config: ConfigService;
+  let injector: AssociationInjector;
   let spec: Spectator<HomeComponent>;
 
   const createComponent = createRoutingFactory({
     component: HomeComponent,
-    declarations: [CardsComponent, MockComponent(CardComponent)],
     imports: [
       IconsModule,
       LoadingComponent,
@@ -53,24 +53,38 @@ describe("HomeComponent", () => {
   });
 
   async function awaitRegions(regions: Errorable<Region[]>) {
+    if (!isBawApiError(regions)) {
+      regions.forEach((model) => model["injector"] = injector);
+    }
+
     const promise = interceptFilterApiRequest<IRegion, Region>(
       regionApi,
       undefined,
       regions,
       Region
     );
+
+    // TODO: once we wrap the example project cards in a defer block, we should
+    // re-enable defer block testing here.
+    // await spec.deferBlock().renderComplete();
     spec.detectChanges();
     await promise;
     spec.detectChanges();
   }
 
   async function awaitProjects(projects: Errorable<Project[]>) {
+    if (!isBawApiError(projects)) {
+      projects.forEach((model) => model["injector"] = injector);
+    }
+
     const promise = interceptFilterApiRequest<IProject, Project>(
       projectApi,
       undefined,
       projects,
       Project
     );
+
+    // await spec.deferBlock().renderComplete();
     spec.detectChanges();
     await promise;
     spec.detectChanges();
@@ -117,9 +131,18 @@ describe("HomeComponent", () => {
 
   beforeEach(() => {
     spec = createComponent({ detectChanges: false });
-    projectApi = spec.inject(ProjectsService);
-    regionApi = spec.inject(ShallowRegionsService);
+
     config = spec.inject(ConfigService);
+    projectApi = spec.inject(PROJECT.token);
+    regionApi = spec.inject(SHALLOW_REGION.token);
+    recordingService = spec.inject(AUDIO_RECORDING.token);
+
+    injector = spec.inject(ASSOCIATION_INJECTOR);
+
+    projectApi.show.andReturn(of());
+
+    recordingService.filterByRegion.andReturn(of([]));
+    recordingService.filterByProject.andReturn(of([]));
   });
 
   // TODO Re-enable once cms is setup
diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts
index a505ee4cc5..032e9264a3 100644
--- a/src/app/components/home/home.component.ts
+++ b/src/app/components/home/home.component.ts
@@ -20,6 +20,7 @@ import { LoadingComponent } from "@shared/loading/loading.component";
 import { CardsComponent } from "@shared/model-cards/cards/cards.component";
 import { StrongRouteDirective } from "@directives/strongRoute/strong-route.directive";
 import { AuthenticatedImageDirective } from "@directives/image/image.directive";
+import { RenderMode } from "@angular/ssr";
 import { WithLoadingPipe } from "../../pipes/with-loading/with-loading.pipe";
 import { homeCategory, homeMenuItem } from "./home.menus";
 
@@ -141,6 +142,7 @@ HomeComponent.linkToRoute({
   category: homeCategory,
   pageRoute: homeMenuItem,
   fullscreen: true,
+  renderMode: RenderMode.Prerender,
 });
 
 export { HomeComponent };
diff --git a/src/app/components/import-annotations/import-annotations.menu.ts b/src/app/components/import-annotations/import-annotations.menu.ts
index efbce2fed7..244d4d9d57 100644
--- a/src/app/components/import-annotations/import-annotations.menu.ts
+++ b/src/app/components/import-annotations/import-annotations.menu.ts
@@ -41,7 +41,7 @@ export const annotationImportMenuItem = menuRoute({
     retrieveResolvedModel(pageInfo, AudioEventImport)?.name,
   title: (routeData: RouterStateSnapshot): string => {
     const componentModel = routeData.root.firstChild.data;
-    return componentModel?.audioEventImport.model.name;
+    return componentModel?.audioEventImport.model?.name;
   },
 });
 
diff --git a/src/app/components/regions/pages/new/new.component.spec.ts b/src/app/components/regions/pages/new/new.component.spec.ts
index 289f34ec0c..046fe7b14e 100644
--- a/src/app/components/regions/pages/new/new.component.spec.ts
+++ b/src/app/components/regions/pages/new/new.component.spec.ts
@@ -11,7 +11,6 @@ import {
   shallowRegionsRoute,
 } from "@components/regions/regions.routes";
 import { BawApiError } from "@helpers/custom-errors/baw-api-error";
-import { getRouteConfigForPage } from "@helpers/page/pageRouting";
 import { Project } from "@models/Project";
 import { Region } from "@models/Region";
 import {
@@ -32,6 +31,7 @@ import { ToastService } from "@services/toasts/toasts.service";
 import { BehaviorSubject, of, Subject } from "rxjs";
 import { Location } from "@angular/common";
 import { FormComponent } from "@shared/form/form.component";
+import { compileAndSplitRoutes } from "@helpers/page/pageRouting";
 import schema from "../../region.base.json";
 import { RegionNewComponent } from "./new.component";
 
@@ -126,10 +126,10 @@ describe("RegionsNewComponent", () => {
 });
 
 describe("routing and resolvers", () => {
-  const nestedRoutes = regionsRoute.compileRoutes(getRouteConfigForPage);
-  const shallowRoutes = shallowRegionsRoute.compileRoutes(
-    getRouteConfigForPage
-  );
+  const [clientRoutes, _serverRoutes] = compileAndSplitRoutes([
+    regionsRoute,
+    shallowRegionsRoute,
+  ]);
 
   let project: Project;
   let defaultProject: Project;
@@ -148,7 +148,7 @@ describe("routing and resolvers", () => {
     // set up ngMocks according to https://ng-mocks.sudo.eu/guides/routing-resolver
     const builder = MockBuilder([
       RegionNewComponent,
-      RouterModule.forRoot([...nestedRoutes, ...shallowRoutes]),
+      RouterModule.forRoot(clientRoutes),
     ])
       .provide(provideMockBawApi())
       .provide({ provide: ProjectsService, useValue: projectsService });
diff --git a/src/app/components/scripts/scripts.menus.ts b/src/app/components/scripts/scripts.menus.ts
index 0475f10659..fae49e14cb 100644
--- a/src/app/components/scripts/scripts.menus.ts
+++ b/src/app/components/scripts/scripts.menus.ts
@@ -32,7 +32,7 @@ export const scriptMenuItem = menuRoute({
   parent: scriptsMenuItem,
   title: (routeData: RouterStateSnapshot): string => {
     const componentModel = routeData.root.firstChild.data;
-    const scriptName = componentModel.script.model.name;
+    const scriptName = componentModel.script.model?.name;
     return scriptName;
   }
 });
diff --git a/src/app/components/shared/menu/widgets/website-status-warning/website-status-warning.component.ts b/src/app/components/shared/menu/widgets/website-status-warning/website-status-warning.component.ts
index 51b942aeb9..db144e7fe9 100644
--- a/src/app/components/shared/menu/widgets/website-status-warning/website-status-warning.component.ts
+++ b/src/app/components/shared/menu/widgets/website-status-warning/website-status-warning.component.ts
@@ -9,7 +9,7 @@ import { StrongRouteDirective } from "@directives/strongRoute/strong-route.direc
 @Component({
   selector: "baw-website-status-warning",
   template: `
-    @if (!(api.status$ | async)?.[feature]) {
+    @if ((api.status$ | async)?.[feature] === false) {
       <section>
         <div class="alert alert-danger">
           <div>
diff --git a/src/app/components/sites/sites.menus.ts b/src/app/components/sites/sites.menus.ts
index 6dc1a55269..7e1a5b3cf3 100644
--- a/src/app/components/sites/sites.menus.ts
+++ b/src/app/components/sites/sites.menus.ts
@@ -27,7 +27,7 @@ export const siteMenuItem = menuRoute({
   breadcrumbResolve: (pageInfo) => retrieveResolvedModel(pageInfo, Site)?.name,
   title: (routeData: RouterStateSnapshot) => {
     const componentModel = routeData.root.firstChild.data;
-    return componentModel.site.model.name;
+    return componentModel.site.model?.name;
   }
 });
 
diff --git a/src/app/helpers/page/pageComponent.ts b/src/app/helpers/page/pageComponent.ts
index 67e8880c47..ad2c8756a0 100644
--- a/src/app/helpers/page/pageComponent.ts
+++ b/src/app/helpers/page/pageComponent.ts
@@ -22,7 +22,13 @@ export class PageComponent extends withUnsubscribe() implements IPageComponent {
     return PageComponent.pageInfos;
   }
 
-  /** Links the page info to the page component */
+  /**
+   * Links the page info to the page component.
+   * Actually stores references bidirectionally between pageInfos and
+   * components.
+   * When we compile routes, we use the page infos, and the link to their
+   * components to generate the route data.
+   */
   public static linkToRoute(info: IPageInfo): typeof PageComponent {
     const pageInfo = new PageInfo(info);
     pageInfo.setComponent(this);
diff --git a/src/app/helpers/page/pageInfo.ts b/src/app/helpers/page/pageInfo.ts
index cff594a8ef..d14c2edcb1 100644
--- a/src/app/helpers/page/pageInfo.ts
+++ b/src/app/helpers/page/pageInfo.ts
@@ -7,6 +7,7 @@ import {
   ResolverList,
 } from "@interfaces/menusInterfaces";
 import { StrongRoute } from "@interfaces/strongRoute";
+import { RenderMode } from "@angular/ssr";
 import { PageComponent } from "./pageComponent";
 
 export function isIPageInfo(data: Data): data is IPageInfo {
@@ -18,8 +19,6 @@ export function isIPageInfo(data: Data): data is IPageInfo {
  * This stores information required to generate the various menus of the page.
  * Also stores metadata about the page, like the icon to use, and the route
  * for that page.
- *
- * @extends Data
  */
 export interface IPageInfo extends Data {
   category?: Category;
@@ -28,6 +27,18 @@ export interface IPageInfo extends Data {
   fullscreen?: boolean;
   resolvers?: ResolverList;
   menus?: Menus;
+
+  /**
+   * Describes where the page will be rendered.
+   * Developers are expected to set this property on a per-page basis, using the
+   * PageComponent's "linkToRoute" method on PageComponent declaration.
+   *
+   * When the StrongRoute's are compiled into page Routes and ServerRoute's
+   * using the PageInfo, this renderMode will be added to the ServerRoute.
+   *
+   * @default RenderMode.Server
+   */
+  renderMode?: RenderMode;
 }
 
 /**
@@ -40,6 +51,7 @@ export class PageInfo implements IPageInfo {
   public menus: Menus;
   public fullscreen: boolean;
   public resolvers: ResolverList;
+  public renderMode: RenderMode;
 
   public constructor(args: IPageInfo) {
     if (!args.pageRoute) {
@@ -49,6 +61,7 @@ export class PageInfo implements IPageInfo {
 
     Object.assign(this, args);
     this.resolvers = args.resolvers ?? {};
+    this.renderMode = args.renderMode ?? RenderMode.Server;
   }
 
   public setComponent(target: Type<PageComponent>): void {
diff --git a/src/app/helpers/page/pageRouting.spec.ts b/src/app/helpers/page/pageRouting.spec.ts
new file mode 100644
index 0000000000..060fc7994b
--- /dev/null
+++ b/src/app/helpers/page/pageRouting.spec.ts
@@ -0,0 +1,174 @@
+import { RenderMode, ServerRoute } from "@angular/ssr";
+import { StrongRoute } from "@interfaces/strongRoute";
+import { Routes } from "@angular/router";
+import { generatePageInfo } from "@test/fakes/PageInfo";
+import { generateMenuRoute } from "@test/fakes/MenuItem";
+import { compileAndSplitRoutes, splitIndexedStrongRoutes } from "./pageRouting";
+import { IPageInfo, PageInfo } from "./pageInfo";
+
+interface CompileAndSplitRoutesTest {
+  name: string;
+  strongRoutes: StrongRoute[];
+  expected: {
+    clientRoutes: Routes;
+    serverRoutes: ServerRoute[] | any;
+  };
+}
+
+function createMockStrongRoute(
+  path: string,
+  pageInfoData?: Partial<IPageInfo>,
+): StrongRoute {
+  const rootRoute = StrongRoute.newRoot();
+  const testRoute = rootRoute.add(path);
+
+  const testPageInfo = new PageInfo(
+    generatePageInfo({
+      pageRoute: generateMenuRoute({
+        route: testRoute,
+      }),
+      ...pageInfoData,
+    }),
+  );
+
+  testRoute.pageComponent = jasmine.createSpyObj("pageComponent", [
+    "pageInfos",
+  ]);
+  testRoute.pageComponent["pageInfos"] = [testPageInfo];
+
+  return testRoute;
+}
+
+describe("pageRouting", () => {
+  describe("splitIndexedStrongRoutes", () => {
+    it("should correctly split an object with keyed StrongRoute's", () => {
+      const projectsRoute = createMockStrongRoute("projects");
+      const recordingsRoute = createMockStrongRoute("audio_recordings");
+
+      const testInput = {
+        projectList: projectsRoute,
+        projectDetailsRoute: recordingsRoute,
+      };
+
+      const expectedOutput = [projectsRoute, recordingsRoute];
+      const realizedOutput = splitIndexedStrongRoutes(testInput);
+
+      expect(realizedOutput).toEqual(expectedOutput);
+    });
+
+    it("should return an empty array for an object with no keys", () => {
+      const realizedResult = splitIndexedStrongRoutes({});
+      expect(realizedResult).toEqual([]);
+    });
+  });
+
+  describe("compileAndSplitRoutes", () => {
+    const tests: CompileAndSplitRoutesTest[] = [
+      {
+        name: "should correctly split a StrongRoute array into client and server routes",
+        strongRoutes: [
+          StrongRoute.newRoot().add("projects"),
+          StrongRoute.newRoot().add("audio_recordings"),
+        ],
+        expected: {
+          clientRoutes: [{ path: "projects" }, { path: "audio_recordings" }],
+          serverRoutes: [
+            { path: "projects", renderMode: jasmine.anything() },
+            { path: "audio_recordings", renderMode: jasmine.anything() },
+          ],
+        },
+      },
+      {
+        name: "should behave correctly if one StrongRoute is passed in",
+        strongRoutes: [createMockStrongRoute("projects")],
+        expected: {
+          clientRoutes: [{ path: "projects" }],
+          serverRoutes: [{ path: "projects", renderMode: jasmine.any(Number) }],
+        },
+      },
+      {
+        name: "should return no routes if no strong routes are passed in",
+        strongRoutes: [],
+        expected: {
+          clientRoutes: [],
+          serverRoutes: [],
+        },
+      },
+    ];
+
+    for (const test of tests) {
+      it(test.name, () => {
+        const [clientRoutes, serverRoutes] = compileAndSplitRoutes(
+          test.strongRoutes,
+        );
+
+        // There should be one server route for every client route.
+        // Therefore, there should be the same number of client routes as server
+        // routes.
+        expect(clientRoutes.length).toEqual(serverRoutes.length);
+
+        for (const routeIndex in clientRoutes) {
+          const expectedClientRoute = test.expected.clientRoutes[routeIndex];
+          const realizedClientRoute = clientRoutes[routeIndex];
+
+          const expectedServerRoute = test.expected.serverRoutes[routeIndex];
+          const realizedServerRoute = serverRoutes[routeIndex];
+
+          expect(realizedClientRoute).toEqual(
+            jasmine.objectContaining(expectedClientRoute),
+          );
+
+          expect(realizedServerRoute).toEqual(
+            jasmine.objectContaining(expectedServerRoute),
+          );
+        }
+      });
+    }
+
+    describe("renderMode", () => {
+      it("should correctly retain 'Client' renderMode overrides", () => {
+        const clientRenderedRoute = createMockStrongRoute("projects", {
+          renderMode: RenderMode.Client,
+        });
+
+        const [clientRoutes, serverRoutes] = compileAndSplitRoutes([
+          clientRenderedRoute,
+        ]);
+
+        expect(clientRoutes.length).toEqual(1);
+        expect(serverRoutes.length).toEqual(1);
+
+        expect(serverRoutes[0]).toEqual(
+          jasmine.objectContaining({
+            renderMode: RenderMode.Client,
+          }),
+        );
+
+        // This assertion is to ensure we should not be leaking ServerRoute
+        // information to the client.
+        expect("renderMode" in clientRoutes[0]).toBeFalse();
+      });
+
+      it("should default to 'Server' renderMode", () => {
+        const serverRenderedRoute = createMockStrongRoute("projects", {
+          renderMode: undefined,
+        });
+
+        const [clientRoutes, serverRoutes] = compileAndSplitRoutes([
+          serverRenderedRoute,
+        ]);
+
+        expect(clientRoutes.length).toEqual(1);
+        expect(serverRoutes.length).toEqual(1);
+
+        expect(serverRoutes[0]).toEqual(
+          jasmine.objectContaining({
+            renderMode: RenderMode.Server,
+          }),
+        );
+
+        expect("renderMode" in clientRoutes[0]).toBeFalse();
+      });
+    });
+  });
+});
diff --git a/src/app/helpers/page/pageRouting.ts b/src/app/helpers/page/pageRouting.ts
index fe3f02286d..9bd5c6e1ae 100644
--- a/src/app/helpers/page/pageRouting.ts
+++ b/src/app/helpers/page/pageRouting.ts
@@ -1,23 +1,42 @@
-import { Route } from "@angular/router";
+import { Route, Routes } from "@angular/router";
 import { ResolverHandlerComponent } from "@components/error/resolver-handler.component";
 import { FormTouchedGuard } from "@guards/form/form.guard";
 import { Option } from "@helpers/advancedTypes";
 import { isInstantiated } from "@helpers/isInstantiated/isInstantiated";
 import { StrongRoute } from "@interfaces/strongRoute";
 import { UnsavedInputGuard } from "@guards/input/input.guard";
+import { RenderMode, ServerRoute } from "@angular/ssr";
 import { getPageInfos } from "./pageComponent";
 
+export function splitIndexedStrongRoutes(
+  routes: Record<string, StrongRoute>,
+): StrongRoute[] {
+  return Object.values(routes).flat();
+}
+
+export function compileAndSplitRoutes(
+  strongRoutes: StrongRoute[],
+): [Routes, ServerRoute[]] {
+  const clientRoutes = strongRoutes
+    .flatMap((route) => route.compileRoutes(createClientRoute))
+
+  const serverRoutes = strongRoutes
+    .flatMap((route) => route.compileRoutes(createServerRoute));
+
+  return [clientRoutes, serverRoutes];
+}
+
 /**
- * Dynamically create routes for an angular component
+ * Dynamically create client routes for an angular component
  *
  * @param page Angular component page info
  * @returns List of routes
  */
-export function getRouteConfigForPage(strongRoute: StrongRoute): Option<Route> {
+function createClientRoute(strongRoute: StrongRoute): Option<Route> {
   const component = strongRoute.pageComponent;
   const config = strongRoute.angularRouteConfig;
   const pageInfo = getPageInfos(component)?.find(
-    (info) => info.route === strongRoute
+    (info) => info.route === strongRoute,
   );
 
   if (!pageInfo || !isInstantiated(pageInfo.route.pathFragment)) {
@@ -42,11 +61,25 @@ export function getRouteConfigForPage(strongRoute: StrongRoute): Option<Route> {
         component: ResolverHandlerComponent,
       },
     ],
-  } as Route;
+  };
 }
 
-export function getRouteConfigForIndexed(routes: Record<string, StrongRoute>) {
-  return Object.values(routes)
-    .map((route) => route.compileRoutes(getRouteConfigForPage))
-    .flat();
+function createServerRoute(strongRoute: StrongRoute): Option<ServerRoute> {
+  const component = strongRoute.pageComponent;
+  const config = strongRoute.angularRouteConfig;
+  const pageInfo = getPageInfos(component)?.find(
+    (info) => info.route === strongRoute,
+  );
+
+  if (!pageInfo || !isInstantiated(pageInfo.route.pathFragment)) {
+    return null;
+  }
+
+  // by default, we want to render the page on the server
+  const renderMode = pageInfo.renderMode ?? RenderMode.Server;
+
+  return {
+    path: config.path,
+    renderMode,
+  } as ServerRoute;
 }
diff --git a/src/app/interfaces/strongRoute.spec.ts b/src/app/interfaces/strongRoute.spec.ts
index d0535d4cbf..0ddab8cdce 100644
--- a/src/app/interfaces/strongRoute.spec.ts
+++ b/src/app/interfaces/strongRoute.spec.ts
@@ -566,19 +566,20 @@ describe("StrongRoute", () => {
         });
 
         it("should compile StrongRoute with custom config", () => {
-          const routes = [
+          const expectedRoutes = [
             rootRoute,
             createRoute(parent.baseRef + ":id", MockComponent, {
               redirectTo: "/test",
             }),
           ];
-          const paramRoute = strongRoute.add(":id", undefined, {
+
+          const testedRoute = strongRoute.add(":id", undefined, {
             redirectTo: "/test",
           });
-          paramRoute.pageComponent = MockComponent;
+          testedRoute.pageComponent = MockComponent;
 
-          const compiledRoutes = paramRoute.compileRoutes(callback);
-          expect(compiledRoutes).toEqual(routes);
+          const compiledRoutes = testedRoute.compileRoutes(callback);
+          expect(compiledRoutes).toEqual(expectedRoutes);
         });
 
         it("should order child StrongRoute routes", () => {
diff --git a/src/app/interfaces/strongRoute.ts b/src/app/interfaces/strongRoute.ts
index 89008e52cd..73349fded2 100644
--- a/src/app/interfaces/strongRoute.ts
+++ b/src/app/interfaces/strongRoute.ts
@@ -1,11 +1,14 @@
 import { Type } from "@angular/core";
-import { Params, Route, Routes } from "@angular/router";
+import { Params, Route } from "@angular/router";
+import { ServerRoute } from "@angular/ssr";
 import { ResolvedModelList } from "@baw-api/resolver-common";
 import { Option } from "@helpers/advancedTypes";
 import { isInstantiated } from "@helpers/isInstantiated/isInstantiated";
 import { PageComponent } from "@helpers/page/pageComponent";
 
-export type RouteConfigCallback = (strongRoute: StrongRoute) => Route;
+export type RouteConfigCallback<T extends Route | ServerRoute> = (
+  strongRoute: StrongRoute,
+) => T;
 
 export type RouteParams = Record<string, string | number>;
 
@@ -97,7 +100,7 @@ export class StrongRoute {
     public readonly pathFragment: string = StrongRoute.rootPath,
     public readonly queryParams: QSPCallback = () => ({}),
     public readonly angularRouteConfig: Partial<Route> = {},
-    isRoot?: boolean
+    isRoot?: boolean,
   ) {
     this.#parent = parent;
 
@@ -146,7 +149,7 @@ export class StrongRoute {
   public add(
     pathFragment: string,
     queryParams?: QSPCallback,
-    angularRouteConfig?: Partial<Route>
+    angularRouteConfig?: Partial<Route>,
   ) {
     return new StrongRoute(this, pathFragment, queryParams, angularRouteConfig);
   }
@@ -165,14 +168,14 @@ export class StrongRoute {
   public addFeatureModule(
     pathFragment: string,
     queryParams?: QSPCallback,
-    angularRouteConfig?: Partial<Route>
+    angularRouteConfig?: Partial<Route>,
   ) {
     return new StrongRoute(
       this,
       pathFragment,
       queryParams,
       angularRouteConfig,
-      true
+      true,
     );
   }
 
@@ -285,7 +288,7 @@ export class StrongRoute {
   public format(
     routeParams: RouteParams = {},
     queryParams: Params = {},
-    resolvedModels: ResolvedModelList = {}
+    resolvedModels: ResolvedModelList = {},
   ): string {
     const qsp = this.queryParams(queryParams, resolvedModels);
     const keys = Object.keys(qsp);
@@ -317,11 +320,13 @@ export class StrongRoute {
    * which allows the 'pages' to add extra route data or modifications when
    * they are set up
    */
-  public compileRoutes(callback: RouteConfigCallback): Routes {
+  public compileRoutes<T extends Route | ServerRoute>(
+    callback: RouteConfigCallback<T>,
+  ): T[] {
     const rootRoute = this.root;
-    const output: Routes = [];
+    const output: T[] = [];
 
-    const sortRoutes = (a: Route, b: Route): -1 | 0 | 1 => {
+    const sortRoutes = (a: T, b: T): -1 | 0 | 1 => {
       // Root route wins
       if (a.path === StrongRoute.rootPath || b.path === StrongRoute.rootPath) {
         // Cannot have multiple root routes
@@ -348,22 +353,38 @@ export class StrongRoute {
       return 0;
     };
 
+    const isServerRoute = (route: Route | ServerRoute): route is ServerRoute =>
+      "renderMode" in route;
+
     const recursiveAdd = (current: StrongRoute): void => {
       const route = callback(current);
       current.children.forEach(recursiveAdd);
 
-      // Ignore root route with no component
-      if (
-        route &&
-        !(
-          route.path === StrongRoute.rootPath &&
-          route.children[0].component === undefined
-        )
-      ) {
+
+      // If a StrongRoute does not have PageInfo (it is not attached to a page)
+      // then the route will end up as "null" when compiled.
+      //
+      // TODO: The strong route should not produce "null" when compiling routes
+      // but I have retained this functionality from a previous version to until
+      // I fully understand why we originally implemented it this way.
+      if (!route) {
+        return;
+      }
+
+      if (isServerRoute(route)) {
         output.push(route);
+      } else {
+        // we only want to add a client route if it has a PageComponent attached
+        if (
+          !(
+            route.path === StrongRoute.rootPath &&
+            route.children[0].component === undefined
+          )
+        ) {
+          output.push(route);
+        }
       }
     };
-
     recursiveAdd(rootRoute);
     return output.sort(sortRoutes);
   }
diff --git a/src/app/services/universal-device-detector/universal-device-detector.service.ts b/src/app/services/universal-device-detector/universal-device-detector.service.ts
index e0f0d8c9c3..0e91e53c75 100644
--- a/src/app/services/universal-device-detector/universal-device-detector.service.ts
+++ b/src/app/services/universal-device-detector/universal-device-detector.service.ts
@@ -1,8 +1,7 @@
-import { Inject, Injectable, Optional, PLATFORM_ID } from "@angular/core";
+import { Inject, Injectable, Optional, PLATFORM_ID, REQUEST } from "@angular/core";
 import { Request } from "express";
 import { DeviceDetectorService } from "ngx-device-detector";
 import { isPlatformServer } from "@angular/common";
-import { REQUEST } from "../../../express.tokens";
 
 @Injectable()
 export class UniversalDeviceDetectorService extends DeviceDetectorService {
diff --git a/src/app/test/fakes/PageInfo.ts b/src/app/test/fakes/PageInfo.ts
index 4ab5ffb045..99201d074e 100644
--- a/src/app/test/fakes/PageInfo.ts
+++ b/src/app/test/fakes/PageInfo.ts
@@ -1,14 +1,22 @@
+import { RenderMode } from "@angular/ssr";
 import { homeCategory, homeMenuItem } from "@components/home/home.menus";
 import { IPageInfo } from "@helpers/page/pageInfo";
 import { modelData } from "@test/helpers/faker";
 
-export function generatePageInfo(data?: Partial<IPageInfo>): IPageInfo {
+export function generatePageInfo(data?: Partial<IPageInfo>): Required<IPageInfo> {
   return {
     fullscreen: modelData.datatype.boolean(),
     category: homeCategory,
     pageRoute: homeMenuItem,
     resolvers: {},
     menus: {},
+    // We hard code the RenderMode to "Client" instead of using a random value
+    // so that if (in the future) tests start using PageInfo/StrongRoute
+    // routing, they will all have the same rendering strategy.
+    //
+    // By setting a constant value, we can ensure that if a test fails due to a
+    // specific rendering strategy, they will fail consistently and predictably.
+    renderMode: RenderMode.Client,
     ...data,
   };
 }
diff --git a/src/app/test/helpers/baw-client.ts b/src/app/test/helpers/baw-client.ts
index e103254f19..abe7712342 100644
--- a/src/app/test/helpers/baw-client.ts
+++ b/src/app/test/helpers/baw-client.ts
@@ -1,6 +1,6 @@
 import { Type } from "@angular/core";
 import { provideMockBawApi } from "@baw-api/provide-baw-ApiMock";
-import { getRouteConfigForPage } from "@helpers/page/pageRouting";
+import { compileAndSplitRoutes } from "@helpers/page/pageRouting";
 import { StrongRoute } from "@interfaces/strongRoute";
 import { createRoutingFactory, SpectatorRouting } from "@ngneat/spectator";
 import { ConfigService } from "@services/config/config.service";
@@ -16,12 +16,12 @@ export function validateBawClientPage<Component extends Type<any>>(
 ) {
   let spec: SpectatorRouting<Component>;
   let config: ConfigService;
-  const compiledRoutes = routes.compileRoutes(getRouteConfigForPage);
+  const [clientRoutes, _serverRoutes] = compileAndSplitRoutes([routes]);
 
   const createComponent = createRoutingFactory({
     component,
     providers: [provideMockBawApi()],
-    routes: compiledRoutes,
+    routes: clientRoutes,
     stubsEnabled: false,
   });
 
diff --git a/src/express.tokens.ts b/src/express.tokens.ts
deleted file mode 100644
index 73ba8baf22..0000000000
--- a/src/express.tokens.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { InjectionToken } from "@angular/core";
-import { Request, Response } from "express";
-
-export const REQUEST = new InjectionToken<Request>("REQUEST");
-export const RESPONSE = new InjectionToken<Response>("RESPONSE");
diff --git a/src/server.ts b/src/server.ts
index 12cdb71637..bfa80ede33 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -10,23 +10,24 @@ import "zone.js/node";
 import { existsSync, readFileSync } from "fs";
 import { dirname, join, resolve } from "path";
 import { fileURLToPath } from "url";
-import { APP_BASE_HREF } from "@angular/common";
-import { Configuration } from "@helpers/app-initializer/app-initializer";
-import { CommonEngine, isMainModule } from "@angular/ssr/node";
+import {
+  isMainModule,
+  AngularNodeAppEngine,
+  writeResponseToNodeResponse,
+  createNodeRequestHandler,
+} from "@angular/ssr/node";
 import { assetRoot } from "@services/config/config.service";
 import express from "express";
 import { environment } from "src/environments/environment";
-import { API_CONFIG } from "@services/config/config.tokens";
-import bootstrap from "src/main.server";
 import angularConfig from "../angular.json";
-import { REQUEST, RESPONSE } from "./express.tokens";
 
 // The Express app is exported so that it can be used by serverless Functions.
 export function app(path: string): express.Express {
-  const server = express();
   const serverDistFolder = dirname(fileURLToPath(import.meta.url));
   const browserDistFolder = resolve(serverDistFolder, "../browser");
-  const indexHtml = join(serverDistFolder, "index.server.html");
+
+  const server = express();
+  const angularApp = new AngularNodeAppEngine();
 
   const configPath = [
     path,
@@ -34,30 +35,16 @@ export function app(path: string): express.Express {
     "/environment.json",
     // development settings
     join(browserDistFolder, "assets", "environment.json"),
+    "./src/assets/environment.json",
   ].find((x) => existsSync(x));
 
   // eslint-disable-next-line no-console
   console.log("Using config file ", configPath);
   const rawConfig = readFileSync(configPath, "utf-8");
-  const config = new Configuration(JSON.parse(rawConfig));
-  const apiConfig = { provide: API_CONFIG, useValue: config };
-
-  const commonEngine = new CommonEngine({
-    providers: [apiConfig],
-  });
 
   server.set("view engine", "html");
   server.set("views", browserDistFolder);
 
-  /*
-   * This allows us to reduce the chances of click-jacking by ensuring that the
-   * site cannot be embedded into another site
-   */
-  server.get("*", (_, res, next) => {
-    res.setHeader("X-Frame-Options", "SAMEORIGIN");
-    next();
-  });
-
   // we add the COOP and COEP headers so that we can use SharedArrayBuffer
   // if you want to update these headers, update the angular.json file
   // so that the headers are updated in the dev server as well
@@ -65,13 +52,17 @@ export function app(path: string): express.Express {
     // we use the angular.json config as the source of truth for headers
     // so that we don't have to maintain the same headers for both the dev
     // server and the production SSR server
-    const serverHeaders = angularConfig.projects["workbench-client"].architect.serve.options.headers;
+    const serverHeaders =
+      angularConfig.projects["workbench-client"].architect.serve.options
+        .headers;
     for (const [key, value] of Object.entries(serverHeaders)) {
       res.setHeader(key, value);
     }
 
+    res.setHeader("X-Frame-Options", "SAMEORIGIN");
+
     next();
-  })
+  });
 
   // special case rendering our settings file - we already have it loaded
   server.get(`${assetRoot}/environment.json`, (_request, response) => {
@@ -80,57 +71,45 @@ export function app(path: string): express.Express {
   });
 
   // Serve static files from /browser
-  server.get(
-    "*.*",
+  server.use(
     express.static(browserDistFolder, {
       maxAge: "1y",
-    })
+      index: false,
+      redirect: false,
+    }),
   );
 
   // All regular routes use the Angular engine
-  server.get("*", (req, res, next) => {
-    const { protocol, originalUrl, baseUrl, headers } = req;
-
-    commonEngine
-      .render({
-        bootstrap,
-        documentFilePath: indexHtml,
-        url: `${protocol}://${headers.host}${originalUrl}`,
-        publicPath: browserDistFolder,
-        providers: [
-          { provide: APP_BASE_HREF, useValue: baseUrl },
-          { provide: RESPONSE, useValue: res },
-          { provide: REQUEST, useValue: req },
-          apiConfig,
-        ],
-      })
-      .then((html) => res.send(html))
-      .catch((err) => next(err));
+  server.get("/**", (req, res, next) => {
+    angularApp
+      .handle(req)
+      .then((response) =>
+        response ? writeResponseToNodeResponse(response, res) : next(),
+      )
+      .catch(next);
   });
 
   return server;
 }
 
-function run(configPath: string): void {
+// first argument after this script's name
+const configPath = process.argv[2];
+const server = app(configPath);
+
+/**
+ * Start the server if this module is the main entry point.
+ * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
+ */
+if (isMainModule(import.meta.url)) {
   const port = Number(process.env.PORT) || 4000;
 
   // eslint-disable-next-line no-console
   console.log("Is production?", environment.production);
-  // Start up the Node server
-  const server = app(configPath);
+
   server.listen(port, "0.0.0.0", () => {
     // eslint-disable-next-line no-console
     console.log(`Node Express server listening on http://0.0.0.0:${port}`);
   });
 }
 
-/**
- * Start the server if this module is the main entry point.
- * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
- */
-if (isMainModule(import.meta.url)) {
-  // first argument after this script's name
-  run(process.argv[2]);
-}
-
-export default app;
+export const reqHandler = createNodeRequestHandler(server);
diff --git a/workbench-client.code-workspace b/workbench-client.code-workspace
index 43a72b0b98..6ca8f140cd 100644
--- a/workbench-client.code-workspace
+++ b/workbench-client.code-workspace
@@ -60,6 +60,7 @@
       "clearfix",
       "Millis",
       "prerender",
+      "SAMEORIGIN",
     ],
     "cSpell.flagWords": [
       "yuo",


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR migrates the application to use Angular’s server-side routing engine, enabling hybrid rendering and per-page renderMode settings.

- Replace custom CommonEngine-based SSR with `AngularNodeAppEngine` and update server startup logic.
- Introduce `renderMode` to page info and routing, and configure pages (home, new project) accordingly.
- Add new `src/server.new.ts`, register server routes in `app.config.ts`, and adjust `angular.json` SSR settings.

### Reviewed Changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

<details>
<summary>Show a summary per file</summary>

| File                                 | Description                                         |
|--------------------------------------|-----------------------------------------------------|
| src/server.ts                        | Switch to `AngularNodeAppEngine`, remove old SSR imports and engine usage. |
| src/server.new.ts                    | New simplified server entry with Express and AngularNodeAppEngine. |
| src/app/helpers/page/pageRouting.ts  | Default `renderMode` added to route config.         |
| src/app/helpers/page/pageInfo.ts     | Add `renderMode` property to `PageInfo` interface and class. |
| src/app/components/projects/pages/new/new.component.ts | Set `renderMode: RenderMode.Client` for new project page. |
| src/app/components/home/home.component.ts             | Set `renderMode: RenderMode.Prerender` for home page. |
| src/app/components/home/home.component.html           | Wrap model cards in `@defer` block with placeholder. |
| src/app/app.routes.server.ts          | Export `ServerRoute[]` from existing routes.       |
| src/app/app.config.ts                | Provide server routing via `provideServerRouting`.  |
| angular.json                         | Update SSR `ssr` entry and set `outputMode` to server. |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/server.new.ts:56**
* `environment` is used but not imported. Add `import { environment } from 'src/environments/environment';` or remove this log.
```
console.log("Is production?", environment.production);
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T03:37:08Z
user: login: atruskie
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T03:44:44Z
user: login: hudson-newey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T05:09:16Z
user: login: atruskie
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-19T00:35:34Z
user: login: hudson-newey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T00:46:31Z
user: login: hudson-newey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T00:54:54Z

Review comments:
diff_hunk: @@ -10,23 +10,24 @@ import "zone.js/node";
 import { existsSync, readFileSync } from "fs";
 import { dirname, join, resolve } from "path";
 import { fileURLToPath } from "url";
-import { APP_BASE_HREF } from "@angular/common";
-import { Configuration } from "@helpers/app-initializer/app-initializer";
-import { CommonEngine, isMainModule } from "@angular/ssr/node";
+// import { Configuration } from "@helpers/app-initializer/app-initializer";
path: src/server.ts
user: login: Copilot
body: [nitpick] Remove legacy commented-out SSR imports and code once migration is complete to improve readability and avoid confusion.
```suggestion

```
created_at: 2025-05-15T03:37:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,77 @@
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+  AngularNodeAppEngine,
+  createNodeRequestHandler,
+  isMainModule,
+  writeResponseToNodeResponse,
+} from "@angular/ssr/node";
+import express from "express";
+
+const serverDistFolder = dirname(fileURLToPath(import.meta.url));
+const browserDistFolder = resolve(serverDistFolder, "../browser");
+
+const app = express();
path: src/server.new.ts
user: login: Copilot
body: The Express app variable is shadowed by the `run` invocation. Rename this variable (e.g., `server`) and update `run` to call `server.listen(...)` instead of `app(configPath)` to avoid runtime errors.
```suggestion
const server = express();
```
created_at: 2025-05-15T03:37:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,6 +45,7 @@ export class PageInfo implements IPageInfo {
   public menus: Menus;
   public fullscreen: boolean;
   public resolvers: ResolverList;
+  public renderMode: RenderMode;
path: src/app/helpers/page/pageInfo.ts
user: login: Copilot
body: Initialize `renderMode` in the constructor (e.g., `this.renderMode = args.renderMode ?? RenderMode.Server;`) to ensure it never remains undefined at runtime.
created_at: 2025-05-15T03:37:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -62,6 +63,7 @@ ProjectNewComponent.linkToRoute({
   category: projectsCategory,
   pageRoute: newProjectMenuItem,
   menus: { actions: List(projectsMenuItemActions) },
+  renderMode: RenderMode.Client,
path: src/app/components/projects/pages/new/new.component.ts
user: login: atruskie
body: Add a comment explaining why this route in particular is client rendering?
created_at: 2025-05-15T03:37:53Z
author_association: MEMBER
diff_hunk: @@ -28,6 +27,12 @@ export interface IPageInfo extends Data {
   fullscreen?: boolean;
   resolvers?: ResolverList;
   menus?: Menus;
+
+  /**
+   * Describes where the page will be rendered.
+   * @default RenderMode.Server
path: src/app/helpers/page/pageInfo.ts
user: login: atruskie
body: add a comment about how our linkToRoute(?) function plumbs this into the route data for the page
created_at: 2025-05-15T03:38:30Z
author_association: MEMBER
path: src/server.new.ts
user: login: atruskie
body: i assume this is WIP and we'll just keep one server.ts?
created_at: 2025-05-15T03:44:10Z
author_association: MEMBER
diff_hunk: @@ -62,6 +63,7 @@ ProjectNewComponent.linkToRoute({
   category: projectsCategory,
   pageRoute: newProjectMenuItem,
   menus: { actions: List(projectsMenuItemActions) },
+  renderMode: RenderMode.Client,
path: src/app/components/projects/pages/new/new.component.ts
user: login: hudson-newey
body: Reverted
created_at: 2025-05-15T05:09:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,14 +1,20 @@
+import { RenderMode } from "@angular/ssr";
 import { homeCategory, homeMenuItem } from "@components/home/home.menus";
 import { IPageInfo } from "@helpers/page/pageInfo";
 import { modelData } from "@test/helpers/faker";
 
-export function generatePageInfo(data?: Partial<IPageInfo>): IPageInfo {
+export function generatePageInfo(data?: Partial<IPageInfo>): Required<IPageInfo> {
   return {
     fullscreen: modelData.datatype.boolean(),
     category: homeCategory,
     pageRoute: homeMenuItem,
     resolvers: {},
     menus: {},
+    renderMode: modelData.helpers.arrayElement([
+      RenderMode.Server,
+      RenderMode.Client,
+      RenderMode.Prerender,
+    ]),
path: src/app/test/fakes/PageInfo.ts
user: login: atruskie
body: probably don't want to randomize this, it could create flaky tests that render different ways? It depends how often we use this pageinfo generator/helper...
created_at: 2025-05-19T00:34:52Z
author_association: MEMBER
diff_hunk: @@ -1,14 +1,20 @@
+import { RenderMode } from "@angular/ssr";
 import { homeCategory, homeMenuItem } from "@components/home/home.menus";
 import { IPageInfo } from "@helpers/page/pageInfo";
 import { modelData } from "@test/helpers/faker";
 
-export function generatePageInfo(data?: Partial<IPageInfo>): IPageInfo {
+export function generatePageInfo(data?: Partial<IPageInfo>): Required<IPageInfo> {
   return {
     fullscreen: modelData.datatype.boolean(),
     category: homeCategory,
     pageRoute: homeMenuItem,
     resolvers: {},
     menus: {},
+    renderMode: modelData.helpers.arrayElement([
+      RenderMode.Server,
+      RenderMode.Client,
+      RenderMode.Prerender,
+    ]),
path: src/app/test/fakes/PageInfo.ts
user: login: hudson-newey
body: We use the `PageInfo` generator/helper a lot.

Most of our Testbed's don't actually have a router, and those that do provide their own router, meaning that the mocked routes don't use `StrongRoute`'s or `PageInfo` at all.

e.g.
https://github.com/QutEcoacoustics/workbench-client/blob/bfee1a82c92a8f56d2b192dbb42cdb55191c1545/src/app/directives/strongRoute/strong-route-active.directive.spec.ts#L38-L48

Additionally, all of our tests are client-side only.\
We don't have any ssr or e2e tests.
Meaning that it's not possible to provide `ServerRoute`'s (which is the type of route that takes a `RenderMode`. Client side Route's cannot take a `RenderMode`).
created_at: 2025-05-19T00:46:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,14 +1,20 @@
+import { RenderMode } from "@angular/ssr";
 import { homeCategory, homeMenuItem } from "@components/home/home.menus";
 import { IPageInfo } from "@helpers/page/pageInfo";
 import { modelData } from "@test/helpers/faker";
 
-export function generatePageInfo(data?: Partial<IPageInfo>): IPageInfo {
+export function generatePageInfo(data?: Partial<IPageInfo>): Required<IPageInfo> {
   return {
     fullscreen: modelData.datatype.boolean(),
     category: homeCategory,
     pageRoute: homeMenuItem,
     resolvers: {},
     menus: {},
+    renderMode: modelData.helpers.arrayElement([
+      RenderMode.Server,
+      RenderMode.Client,
+      RenderMode.Prerender,
+    ]),
path: src/app/test/fakes/PageInfo.ts
user: login: hudson-newey
body: ![image](https://github.com/user-attachments/assets/f797e8b7-aebb-4b1d-b963-a71f44ed16da)

created_at: 2025-05-19T00:54:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-15T05:21:15Z
body: **Size Change:** +52.5 kB (+1.76%) 

**Total Size:** 3.03 MB

| Filename | Size | Change |  |
| :--- | :---: | :---: | :---: |
| `dist/workbench-client/browser/chunk-2LLLORK2.js` | 0 B | -262 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-BQUZCGN2.js` | 0 B | -2.08 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-DG5YXQFO.js` | 0 B | -4.53 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-DHP3DXHF.js` | 0 B | -4 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-IBIXOX5S.js` | 0 B | -1.53 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-ISDIUKDA.js` | 0 B | -1.55 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-JQFKLS7Q.js` | 0 B | -1.58 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-KCJJMMRM.js` | 0 B | -4.37 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-LETZML3Y.js` | 0 B | -532 B (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-LOHIAHLB.js` | 0 B | -947 B (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-PUXE7NNX.js` | 0 B | -1.97 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-PX3X3GLP.js` | 0 B | -3.93 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-S7ZKITGW.js` | 0 B | -1.93 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-TO3PIT4Y.js` | 1.28 kB | +1 B (+0.08%) |  |
| `dist/workbench-client/browser/chunk-XSVAY7Y3.js` | 0 B | -1.28 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-YN454KAZ.js` | 0 B | -3.1 kB (removed) | 🏆 |
| `dist/workbench-client/browser/chunk-ZXBYLWBA.js` | 0 B | -91.4 kB (removed) | 🏆 |
| `dist/workbench-client/browser/main-BIWVUMYB.js` | 0 B | -1.17 MB (removed) | 🏆 |
| `dist/workbench-client/prerendered-routes.json` | 50 B | +12 B (+31.58%) | 🚨 |
| `dist/workbench-client/browser/chunk-5USAV3LW.js` | 1.28 kB | +1.28 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-5YMXIDQV.js` | 1.55 kB | +1.55 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-7DHPLWAT.js` | 4 kB | +4 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-7VVB7H2U.js` | 947 B | +947 B (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-AK3LNZVF.js` | 1.97 kB | +1.97 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-EGOYZ6OJ.js` | 262 kB | +262 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-J4GBVJO3.js` | 1.53 kB | +1.53 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-LRKGWJ76.js` | 531 B | +531 B (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-M4O5CXWS.js` | 3.93 kB | +3.93 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-N5NBH2WN.js` | 4.37 kB | +4.37 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-OQ2VMPD5.js` | 1.93 kB | +1.93 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-RYU22HDX.js` | 91.4 kB | +91.4 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-SBJHFSR2.js` | 4.53 kB | +4.53 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-SG4XVVRQ.js` | 1.58 kB | +1.58 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-TXBQOSDS.js` | 3.1 kB | +3.1 kB (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-USQ5FB45.js` | 112 B | +112 B (new file) | 🆕 |
| `dist/workbench-client/browser/chunk-Z2LWDJS2.js` | 2.07 kB | +2.07 kB (new file) | 🆕 |
| `dist/workbench-client/browser/index.html` | 52.2 kB | +52.2 kB (new file) | 🆕 |
| `dist/workbench-client/browser/main-LGEX3IF7.js` | 1.17 MB | +1.17 MB (new file) | 🆕 |

<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size |
| :--- | :---: |
| `dist/workbench-client/browser/assets/buffer-builder-processor-BhnxGUx8.js` | 1.16 kB |
| `dist/workbench-client/browser/assets/environment.json` | 555 B |
| `dist/workbench-client/browser/assets/high-accuracy-time-processor-BevUJNwo.js` | 354 B |
| `dist/workbench-client/browser/assets/test-assets/index.html` | 21 B |
| `dist/workbench-client/browser/chunk-2HOBZ3IU.js` | 1.36 MB |
| `dist/workbench-client/browser/chunk-MMYNLW6F.js` | 112 B |
| `dist/workbench-client/browser/chunk-S2RXNE54.js` | 3.79 kB |
| `dist/workbench-client/browser/chunk-SAHX3LH5.js` | 1.09 kB |
| `dist/workbench-client/browser/chunk-VCDS3YKU.js` | 563 B |
| `dist/workbench-client/browser/index.***.html` | 4.59 kB |
| `dist/workbench-client/browser/manifest.json` | 147 B |
| `dist/workbench-client/browser/polyfills-YY5MQKDP.js` | 12.7 kB |
| `dist/workbench-client/browser/styles-JXQTZMYO.css` | 38.1 kB |
| `dist/workbench-client/server/index.******.html` | 1.32 kB |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR
user: login: hudson-newey
created_at: 2025-05-16T07:02:16Z
body: I removed `ng-mocks` from the `home.component.spec` tests and they work in isolation when using the `ng test --include` command.

However, even though the `home.component.spec` fixture doesn't use any mocked components, it still breaks when running the entire test suite.

I suspect (I have not checked) this is because one of our other tests use mocked components. Meaning that if we use `ng-mocks` anywhere in our test suite, we immediately lose the ability to test `@defer` blocks.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-19T00:21:09Z
body: ## Unit Test Results
         6 files           6 suites   10m 15s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "duration of all tests")
25 422 tests 24 696 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") 726 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") 0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests")
25 662 runs  24 936 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") 726 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") 0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests")

Results for commit bebfe25e.

[test-results]:data:application/gzip;base64,H4sIADaFKmgC/03MSw6DIBSF4a0Yxh3oBa7SzTQgkJCqNDxGTfdetFYY/t9JzptYt5hI7h3eOhKzS1foHGRyfttz4AXKlPYROAP49yPmed6RocCKT/cqOEIlK91SqL/AhODDKSFvv2dEOLMeC4qXtb+HNLdHt6+zX1eXShBllDXAzQDaMEWpmrQWFqUe2ViUq37SVoElny+lkIMyEQEAAA==

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR