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?
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.