3

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?

Martin Sotirov
  • 351
  • 2
  • 11
  • Are you (not) using `@angular/pwa`? Is the content rendered when you perform CTRL+F5? – Pieterjan Mar 07 '21 at 22:21
  • @Pieterjan I'm not using `@angular/pwa`. The content is rendered with routing on the client when I refresh the page or if I click any Angular route. – Martin Sotirov Mar 07 '21 at 22:31
  • 1
    can you post a snippet of your `angular.json` file as well? Particularly the `server` target under `architect`, and specifically the `outputPath` value. I see in `server.ts` you are reading from `.../browser`, which has the bundles for browser, with lazy-loading. But for server, there's usually a separate outputPath (`../server`) which contains a single main.js bundle without lazy-loading. – ulmas Mar 08 '21 at 01:30
  • I added `angular.json`, `webpack.config.js` and my main server module to my question. Thanks for looking into it! – Martin Sotirov Mar 08 '21 at 07:52
  • UPDATE: I managed to trace the problem back to our usage of trailing slashes for navigation. I guess `@nguniversal/express-engine` doesn't like the trailing slashes for some reason. – Martin Sotirov Mar 08 '21 at 15:22

1 Answers1

1

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]
    }
  ],
})

Martin Sotirov
  • 351
  • 2
  • 11
  • Can you please provide more information about the issue? I am facing the same. – Gergő Éles Jul 09 '21 at 13:46
  • Gergő Éles what's your issue - trailing slashes or lazy loaded modules? – Martin Sotirov Jul 11 '21 at 09:51
  • Lazy loaded modules with SSR only load empty router outlets. Everything seems to work fine no flickering great speed, but when I checked dev tools I see that angular universal only returns HTML with an empty router outlet. Also meta tags - titles are not applied by the universal – Gergő Éles Jul 12 '21 at 11:47
  • @GergőÉles can you provide a StackBlitz recreating the problem? In our case the problem was trailing slashes in the route definitions. We wrote a custom UrlSerializer that adds the trailing slashes only when rendering and thus our routes are still defined without slashes at the end. – Martin Sotirov Aug 05 '21 at 12:02
  • @MartinSotirov can you pls share some code.. this is more helpful for me... im also getting empty router outlet. currently i have fixed by changes the module : commonjs in tsconfig.server.json – Venka Tesh user5397700 Aug 23 '21 at 16:07
  • @GergőÉles @Venka Tesh check out my updated answer with the `UrlSerializer` that enables trailing slashes in the background without you having to put them in the router or in the individual links. – Martin Sotirov Aug 24 '21 at 17:28
  • On my side, it turned out that session based RouterGuards were causing the issue. – Gergő Éles Aug 24 '21 at 17:40
  • @MartinSotirov can u explain how / where i need to include this code – Venka Tesh user5397700 Aug 27 '21 at 10:31
  • @GergőÉles can u help me on this please – Venka Tesh user5397700 Aug 29 '21 at 17:27
  • @MartinSotirov GergoEles could u please help me – Venka Tesh user5397700 Sep 03 '21 at 16:04
  • where exactly should we write this snippet? please share your repo – Sh eldeeb Mar 13 '22 at 21:15
  • 1
    @Sheldeeb @VenkaTeshuser5397700 guys, just use it like a normal service provider in your AppModule's @NgModule decorator. Make sure to inject the `PLATFORM_ID` via the `deps` array, so that the serialiser knows if it's doing SSR. I can't share you my repo, because it's for a customer project. See my updated code snippet in the accepted answer above. – Martin Sotirov Mar 15 '22 at 08:29