angularnestjsserver-side-rendering

NestJS as BFF for angular 19 using SSR


Angular 19 comes along with big improvements to how SSR works. In particular, the server part is now actually used when booting up the vite dev server. Which means that I can use express as my reverse proxy so I do not have to define my proxies as a json config for development and once again in code for production. Great!

This got me thinking; I know about separation of concerns and frontends should only do frontend and backends should only do backend, right? But using SSR, my frontend is already served from a backend. Why should I have a backend for frontend and another separate backend for backend? Well, I don't think I need to. The changes made to @angular/ssr package allows me to have a full blown expressjs server as my BFF dev and prod server with all the percs that follows. So not only serving my frontend with hybrid rehydration and providing reverse proxies, I can also define my actual backend api here. All from just a simple ng serve.

But it is kind of tiresome to define backend endpoints and their logic in express. I would want to leverage a backend framework like NestJS instead. This is where I come to a halt, because I just cannot get my NestJS controllers to respond in this setup.

This works:

function widgetRoutes(server: express.Express) {
  const widgetData = [
    { id: 1, name: 'Weather', componentName: 'weather' },
    { id: 2, name: 'Taxes', componentName: 'widget2' },
    { id: 3, name: 'Something else', componentName: 'widget3' },
  ];

  server.get('/api/widgets', (req, res) => {
    res.json(widgetData);
  });

  /**
   * Fetch a single widget by ID.
   */
  server.get('/api/widgets/:id', (req, res) => {
    if (req.params.id) {
      const widget = widgetData.find((w) => w.id === +req.params.id);
      if (!widget) {
        res.status(404).json({ error: 'Widget not found' });
      } else {
        res.json([widget]);
      }
    }
    console.log('[APP]', req.method, req.url, res.statusCode);
  });
}

export function bootstrap(): express.Express {
  const server = express();
  const serverDistFolder = dirname(fileURLToPath(import.meta.url));
  const browserDistFolder = resolve(serverDistFolder, '../browser');

  // Here, we now use the `AngularNodeAppEngine` instead of the `CommonEngine`
  const angularNodeAppEngine = new AngularNodeAppEngine();

  // Setup api routes
  widgetRoutes(server);

  // Setup reverse proxy routes
  Object.entries(proxyRoutes).forEach(([path, config]) =>
    server.get(path, createProxyMiddleware(config)),
  );

  // Serve static files from the browser distribution folder
  server.get(
    '**',
    express.static(browserDistFolder, {
      maxAge: '1y',
      index: 'index.html',
    }),
  );

  server.get('**', (req, res, next) => {
    // Yes, this is executed in devMode via the Vite DevServer
    console.log('[APP]', req.method, req.url, res.statusCode);

    angularNodeAppEngine
      .handle(req, { server: 'express' })
      .then((response) =>
        response ? writeResponseToNodeResponse(response, res) : next(),
      )
      .catch(next);
  });

  return server;
}

const server = bootstrap();
if (isMainModule(import.meta.url)) {
  const port = process.env['PORT'] || 4000;
  server.listen(port, () => {
    console.log(`Node Express server listening on http://localhost:\${port}`);
  });
}

console.warn('Node Express server started');

// This exposes the RequestHandler
export const reqHandler = createNodeRequestHandler(server);

This doesn't:

@Controller('api/widgets')
export class WidgetController {
  widgetData = [
    { id: 1, name: 'Weather', componentName: 'weather' },
    { id: 2, name: 'Taxes', componentName: 'widget2' },
    { id: 3, name: 'Something else', componentName: 'widget3' },
  ];

  @Get()
  findAll() {
    return this.widgetData;
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.widgetData.find((w) => w.id === +id);
  }
}

@Module({
  // Setup api routes
  controllers: [WidgetController],
})
export class AppModule {}
  
export async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  // Get the express instance from the NestJS app
  const server = app.getHttpAdapter().getInstance();
  const serverDistFolder = dirname(fileURLToPath(import.meta.url));
  const browserDistFolder = resolve(serverDistFolder, '../browser');
  
  // Here, we now use the `AngularNodeAppEngine` instead of the `CommonEngine`
  const angularNodeAppEngine = new AngularNodeAppEngine();
  
  // Setup reverse proxy routes
  Object.entries(proxyRoutes).forEach(([path, config]) =>
    server.get(path, createProxyMiddleware(config)),
  );
  
  // Serve static files from the browser distribution folder
  server.get(
    '**',
    express.static(browserDistFolder, {
      maxAge: '1y',
      index: 'index.html',
    }),
  );

  server.get('**', (req, res, next) => {
    // Yes, this is executed in devMode via the Vite DevServer
    console.log('[APP]', req.method, req.url, res.statusCode);

    angularNodeAppEngine
      .handle(req, { server: 'express' })
      .then((response) =>
        response ? writeResponseToNodeResponse(response, res) : next(),
      )
      .catch(next);
  });

  return app;
}
  
const server = await bootstrap();
if (isMainModule(import.meta.url)) {
  const port = process.env['PORT'] || 4000;
  server.listen(port, () => {
    console.log(`Node Express server listening on http://localhost:\${port}`);
  });
}
  
// This exposes the RequestHandler
export const reqHandler = createNodeRequestHandler(
  server.getHttpAdapter().getInstance(),
);
 

What am I doing wrong? The setup looks to be the same, but the NestJS controller does not respond. Or, if my thoughts stink, why should I not venture down this road?


Solution

  • Got it working:

    @Controller('api/widgets')
    export class WidgetController {
      widgetData = [
        { id: 1, name: 'Weather', componentName: 'weather' },
        { id: 2, name: 'Taxes', componentName: 'widget2' },
        { id: 3, name: 'Something else', componentName: 'widget3' },
      ];
    
      @Get()
      findAll() {
        return this.widgetData;
      }
    
      @Get(':id')
      findOne(@Param('id') id: string) {
        return this.widgetData.find((w) => w.id === +id);
      }
    }
    
    @Module({
      // Setup api routes
      controllers: [WidgetController],
    })
    export class ApiModule {}
      
    export async function bootstrap() {
      // Create the NestJS application
      const app = await NestFactory.create<NestExpressApplication>(ApiModule);
      // Get the Express instance
      const server = app.getHttpAdapter().getInstance();
    
      // Setup reverse proxy routes
      Object.entries(proxyRoutes).forEach(([path, config]) =>
        server.get(path, createProxyMiddleware(config)),
      );
    
      // Serve static files from the browser distribution folder
      const serverDistFolder = dirname(fileURLToPath(import.meta.url));
      const browserDistFolder = resolve(serverDistFolder, '../browser');
      server.get(
        '**',
        express.static(browserDistFolder, {
          maxAge: '1y',
          index: 'index.html',
        }),
      );
    
      // SSR middleware: Render out the angular application server-side
      const angularNodeAppEngine = new AngularNodeAppEngine();
      server.get('**', (req, res, next) => {
        angularNodeAppEngine
          .handle(req, { server: 'express' })
          .then((response) => {
            // If the Angular app returned a response, write it to the Express response
            if (response) {
              const n = writeResponseToNodeResponse(response, res);
              console.log('[SSR]', req.method, req.url, response.status);
              return n;
            }
            // If not, this is not an Angular route, so continue to the next middleware
            return next();
          })
          .catch(next);
      });
    
      // Initialize the NestJS application and return the server
      app.init(); // <-- This is what makes it work
      return server;
    }
      
    const server = await bootstrap();
    if (isMainModule(import.meta.url)) {
      const port = process.env['PORT'] || 4000;
      server.listen(port, () => {
        console.log(`Node Express server listening on http://localhost:\${port}`);
      });
    }
      
    // This exposes the RequestHandler
    export const reqHandler = createNodeRequestHandler(server);
     
    

    I was not initializing the nestjs engine apparently. app.init() did the trick. It is also important that this comes after all the express config. If I put it right after creating the nestjs server, all the reverse proxy and ssr stuff is ignored.

    I didn't see init() as something important in the docs, but perhaps init is run implicitly when I run .listen on the nestJS app. Here I run .listen on only the express instance.