angularlazy-loadingserver-side-renderingeager-loadinguniversal

Lazy-loaded Angular modules don't get server side rendered with @nguniversal, while client side routing and rendering works


We recently broke up our Server Side Rendered Angular 11 app into lazy-loaded modules and now SSR doesn't work. Some of the URLs don't get routed properly and go to the 404 catch-all while others seem to be routed properly but show a white page (empty <router-outlet> contents). With Javascript enabled the content gets properly rendered on the client side or if I navigate to any of the Angular router links.

I read in older tutorials that there used to be a module map for lazy-loaded modules for @nguniversal but that should not be needed with Angular 11.

This is how my routes look like:

const routes: Routes = [
  {
    path: '',
    redirectTo: 'start/',
    pathMatch: 'full'
  },
  {
    path: 'start',
    loadChildren: () => import('../home/home.module').then(m => m.HomeModule)
  },
  {
    path: 'blog',
    loadChildren: () => import('../blog/blog.module').then(m => m.BlogModule)
  },
  {
    path: 'ratgeber',
    loadChildren: () => import('../guide/guide.module').then(m => m.GuideModule)
  },
  {
    path: 'branchenbuch',
    loadChildren: () => import('../vendors/vendors.module').then(m => m.VendorsModule)
  },
  {
    path: 'galerien',
    loadChildren: () => import('../gallery/gallery.module').then(m => m.GalleryModule)
  },
  { path: '404/.', component: NotFoundComponent },
  { path: ':slug/.', component: StaticPageComponent },
  { path: '**', component: NotFoundComponent },

And this is my Express entry in server.ts

export function app(): express.Express {
  const server = express()
  const distFolder = join(process.cwd(), 'dist/hp24-frontend/browser')
  const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index'

  // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
  server.engine('html', ngExpressEngine({
    bootstrap: AppServerModule,
  }))

  server.set('view engine', 'html')
  server.set('views', distFolder)

  // Example Express Rest API endpoints
  // server.get('/api/**', (req, res) => { });
  // Serve static files from /browser
  server.get('*.*', express.static(distFolder, {
    maxAge: '1y'
  }))

  // All regular routes use the Universal engine
  server.get('*', (req, res) => {
    const hostUrl = req.protocol + '://' + (req.get('X-Forwarded-Host') || req.get('Host'))
    res.render(indexHtml, {
      req,
      providers: [
        { provide: APP_BASE_HREF, useValue: req.baseUrl },
        { provide: HOST_URL, useValue: hostUrl },
      ]
    })
  })

  return server
}

This is my angular.json.

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "hp24-frontend": {
      "projectType": "application",
      "schematics": {
        "@schematics/angular:component": {
          "style": "scss"
        }
      },
      "root": "",
      "sourceRoot": "src",
      "prefix": "hp24",
      "architect": {
        "build": {
          "builder": "@angular-builders/custom-webpack:browser",
          "options": {
            "customWebpackConfig": {
              "path": "./webpack.config.js"
            },
            "outputPath": "dist/hp24-frontend/browser",
            "index": "src/index.html",
            "indexTransform": "index-html-transform.ts",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.app.json",
            "aot": true,
            "assets": [
              "src/assets"
            ],
            "styles": [
              "src/styles/styles.scss"
            ],
            "scripts": [],
            "stylePreprocessorOptions": {
              "includePaths": [
                "src/styles"
              ]
            }
          },
          "configurations": {
            "production": {
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.prod.ts"
                }
              ],
              "optimization": true,
              "outputHashing": "all",
              "sourceMap": false,
              "extractCss": true,
              "namedChunks": false,
              "extractLicenses": true,
              "vendorChunk": false,
              "buildOptimizer": true,
              "budgets": [
                {
                  "type": "initial",
                  "maximumWarning": "2mb",
                  "maximumError": "5mb"
                },
                {
                  "type": "anyComponentStyle",
                  "maximumWarning": "6kb",
                  "maximumError": "10kb"
                }
              ]
            }
          }
        },
        "serve": {
          "builder": "@angular-builders/custom-webpack:dev-server",
          "options": {
            "customWebpackConfig": {
              "path": "./webpack.config.js"
            },
            "browserTarget": "hp24-frontend:build"
          },
          "configurations": {
            "production": {
              "browserTarget": "hp24-frontend:build:production"
            }
          }
        },
        "extract-i18n": {
          "builder": "@angular-devkit/build-angular:extract-i18n",
          "options": {
            "browserTarget": "hp24-frontend:build"
          }
        },
        "test": {
          "builder": "@angular-devkit/build-angular:karma",
          "options": {
            "main": "src/test.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.spec.json",
            "karmaConfig": "karma.conf.js",
            "assets": [
              "src/favicon.ico",
              "src/assets"
            ],
            "styles": [
              "src/styles/variables/_colors.scss",
              "src/styles/styles.scss"
            ],
            "scripts": []
          }
        },
        "lint": {
          "builder": "@angular-devkit/build-angular:tslint",
          "options": {
            "tsConfig": [
              "tsconfig.app.json",
              "tsconfig.spec.json",
              "e2e/tsconfig.json",
              "tsconfig.server.json"
            ],
            "exclude": [
              "**/node_modules/**"
            ]
          }
        },
        "e2e": {
          "builder": "@angular-devkit/build-angular:protractor",
          "options": {
            "protractorConfig": "e2e/protractor.conf.js",
            "devServerTarget": "hp24-frontend:serve"
          },
          "configurations": {
            "production": {
              "devServerTarget": "hp24-frontend:serve:production"
            }
          }
        },
        "server": {
          "builder": "@angular-builders/custom-webpack:server",
          "options": {
            "customWebpackConfig": {
              "path": "./webpack.config.js"
            },
            "outputPath": "dist/hp24-frontend/server",
            "main": "server.ts",
            "tsConfig": "tsconfig.server.json"
          },
          "configurations": {
            "production": {
              "outputHashing": "media",
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.prod.ts"
                }
              ],
              "sourceMap": false,
              "optimization": true
            }
          }
        },
        "serve-ssr": {
          "builder": "@nguniversal/builders:ssr-dev-server",
          "options": {
            "browserTarget": "hp24-frontend:build",
            "serverTarget": "hp24-frontend:server"
          },
          "configurations": {
            "production": {
              "browserTarget": "hp24-frontend:build:production",
              "serverTarget": "hp24-frontend:server:production"
            }
          }
        },
        "prerender": {
          "builder": "@nguniversal/builders:prerender",
          "options": {
            "browserTarget": "hp24-frontend:build:production",
            "serverTarget": "hp24-frontend:server:production",
            "routes": [
              "/"
            ]
          },
          "configurations": {
            "production": {}
          }
        }
      }
    }},
  "defaultProject": "hp24-frontend"
}

... and this is my custom webpack file that I use for postcss and tailwind.

const plugins = [
  require('postcss-import'),
  require('tailwindcss'),
  require('autoprefixer'),
]

module.exports = {
  module: {
    rules: [
      {
        test: /\.scss$/,
        loader: 'postcss-loader',
        options: {
          ident: 'postcss',
          syntax: 'postcss-scss',
          plugins: () => plugins
        }
      }
    ]
  }
};

And this is my main server module:

import { NgModule } from '@angular/core'
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server'

import { AppModule } from './app.module'
import { AppComponent } from './app.component'
import { FlexLayoutServerModule } from '@angular/flex-layout/server'
import { SeoService } from './core/services/seo.service'

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    ServerTransferStateModule,
    FlexLayoutServerModule
  ],
  providers: [SeoService],
  bootstrap: [AppComponent],
})
export class AppServerModule {}

Any tips for what I might be missing here?


Solution

  • I found the problem. @nguniversal was stripping my trailing slashes so I had to override the Location.stripTrailingSlash method in my server.ts entry.

    This is my custom UrlSerializer that enables trailing slashes without having to hardcode them in the router or in the individual router links in the components.

    import { UrlTree, DefaultUrlSerializer } from '@angular/router'
    import { isPlatformBrowser } from '@angular/common'
    import { InjectionToken } from '@angular/core'
    
    export class TrailingSlashSerializer extends DefaultUrlSerializer {
      constructor(private platformId: InjectionToken<any>) {
        super()
      }
      serialize(tree: UrlTree): string {
        return this._withTrailingSlash(super.serialize(tree))
      }
    
      parse(url: string): UrlTree {
        if (isPlatformBrowser(this.platformId)) {
          return super.parse(this._withoutDot(url))
        } else {
          return super.parse(url)
        }
      }
    
      private _withoutDot(url: string): string {
        const splitOn = url.indexOf('?') > - 1 ? '?' : '#'
        const pathArr = url.split(splitOn)
    
        if (pathArr[0].endsWith('/.')) {
          pathArr[0] = pathArr[0].slice(0, -2)
        } else if (pathArr[0].endsWith('.')) {
          pathArr[0] = pathArr[0].slice(0, -1)
        }
    
        return pathArr.join(splitOn)
      }
    
      private _withDot(url: string): string {
        if (url.split('/').pop().indexOf('.') === -1) {
          if (url.endsWith('/')) {
            url += '.'
          } else if (!url.endsWith('/') && !url.endsWith('.')) {
            url += '/.'
          }
        }
        return url
      }
    
      private _withTrailingSlash(url: string): string {
        const splitOn = url.indexOf('?') > - 1 ? '?' : '#'
        const pathArr = url.split(splitOn)
    
        if (!pathArr[0].endsWith('/')) {
          const fileName: string = url.substring(url.lastIndexOf('/') + 1)
          if (fileName.indexOf('.') === -1 || fileName.indexOf('?') > -1) {
            pathArr[0] += '/'
          }
        } else {
          pathArr[0] += ''
        }
        return pathArr.join(splitOn)
      }
    }
    
    export const urlSerializerFactory = (platformId: InjectionToken<any>) => {
      return new TrailingSlashSerializer(platformId)
    }
    

    Now just use the serializer factory in your app module's providers like so:

    import { UrlSerializer } from '@angular/router'
    import { urlSerializerFactory } from './providers/trailing-slash.serializer'
    
    @NgModule({
      providers: [
        {
          provide: UrlSerializer,
          useFactory: urlSerializerFactory,
          deps: [PLATFORM_ID]
        }
      ],
    })