Alexey Yakovlev
Posted on January 7, 2022
This is the second and final part in a series of articles about combining nest.js and NEXT.js. In the first part we created a project and discovered a few basic SSR techniques for this stack. At this point the project can already be used as a base for developing a real website. However there are a few more things we can do: enable Hot Module Replacement for nest-next
, discover more reusable SSR techniques and learn of subdirectory deployments.
This post is a translation of the original articles by me on Habr. The translation had no input from experienced tech writers or editors. So any feedback towards correcting any mistakes is greatly appreciated.
Table of Contents
- Table of Contents
- Introduction
- CDN deployment
- HMR and NEXT.js instance caching
- More SSR techniques
- Subdirectory deployment
- Conclusion
Introduction
I should remind you that the final application is available at my GitHub - https://github.com/yakovlev-alexey/nest-next-example - commit history mostly follows the flow of the article. I assume that you are already finished with the first part of series and have the project at the relevant revision (commit 5139ad554f).
NEXT.js and nest.js are very similar in naming so I will be always typing NEXT.js (the React framework) in uppercase and nest.js (the MVC backend framework) in lowercase to differenciate between them more easily.
CDN deployment
Let's start simple. NEXT.js supports CDN (Content Delivery Network) deployments out of the box. Simply put your path to statics as assetPrefix
in your next.config.js
when building for production. Then upload ./.next/static
to your CDN of choice. It is important to note that assetPrefix
also has to be set in the config when launching production nest.js server.
HMR and NEXT.js instance caching
At the moment the application works really fast in dev mode. Reboots should not take more than 3 seconds and pages load up instantly. However this might not be always that way - large projects tend to have significantly crippled build procedures. It is especially noticable for large frontends. Every change to nest.js server would reboot it and create a new NEXT.js instance that would start recompiling your client (almost) from scratch.
This greatly degrades developer experience significantly increasing feedback cycles when developing complex solutions. Let's fix this issue by implementing Hot Module Replacement in nest.js and caching NEXT.js instances between reloads.
Hot Reload in nest.js
We will first follow official documentation on Hot Reload in nest.js. I will not duplicate most of information from there, just the more important parts.
For example, we should use our own start:dev
script to accept a different tsconfig.json
.
// ./package.json
"start:dev": "nest build --webpack --webpackPath webpack-hmr.config.js --path tsconfig.server.json --watch"
It is important to pass
tsconfig.server.json
outside of a relative path:ts-loader
will look up paths relative to the server executable. If supply it just with the file name it will start searching in parent folders until it finds a matching file.
Next we should tackle Hot Reload in main.ts
// ./src/server/main.ts
import { NestFactory } from '@nestjs/core';
import { PORT } from 'src/shared/constants/env';
import { AppModule } from './app.module';
declare const module: any;
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(PORT);
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => app.close());
}
}
bootstrap();
This implementation will works for us. However make notice of old module disposal: app.close()
is called. It causes all modules, services and controller to cease.
Let's check if our server even works after adding HMR. Boot the server and make changes to app.controller.ts
. It is important that the server will not reboot at all if you save the file without making any actual changes. If you did indeed make changes to a file it should take ~1-2 seconds to reboot in small projects and no more than ~4-5 seconds in large ones. It is a huge difference that significantly improve developer experience.
If any nest.js modules were using dynamic
import/require
they may not work after adding Webpack HMR due to bundling. My solution was to boottsc
along with Webpack nest.js executable since dynamically imported files were not frequently changed. You may also need NODE_PATH environment variable to properly resolve dynamic import paths (prependNODE_PATH=<path> ...
tostart:dev
script).
Caching NEXT.js instance
Still we have not solved the bottleneck we had with new NEXT.js instance for each reboot. This may take up to a few seconds in large projects during initialization and page loads.
We will cache RenderModule
from nest-next
between module reloads in app.module.ts
For this we need nest.js dynamic module initialization.
// ./src/server/app.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import Next from 'next';
import { RenderModule } from 'nest-next';
import { NODE_ENV } from 'src/shared/constants/env';
import { AppController } from './app.controller';
import { AppService } from './app.service';
declare const module: any;
@Module({})
export class AppModule {
public static initialize(): DynamicModule {
/* during initialization attempt pulling cached RenderModule
from persisted data */
const renderModule =
module.hot?.data?.renderModule ??
RenderModule.forRootAsync(Next({ dev: NODE_ENV === 'development' }), {
viewsDir: null,
});
if (module.hot) {
/* add a handler to cache RenderModule
before disposing existing module */
module.hot.dispose((data: any) => {
data.renderModule = renderModule;
});
}
return {
module: AppModule,
imports: [renderModule],
controllers: [AppController],
providers: [AppService],
};
}
}
Update main.ts
to dynamically initialize AppModule
.
// ./src/server/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
const app = await NestFactory.create(AppModule.initialize());
To make sure everything works we may observe terminal outputs during nest.js reboot. If we succeeded we will not see compiling...
message produced when creating a new instance of NEXT.js server. This way we potentially saved hours of developers time.
More SSR techniques
Our original problem was that we did not want to have separate services for nest.js and NEXT.js. It seems that at this point we removed all things that were blocking us from having a decent developer experience with our solution. So how about leveraging our solution to have some advantages?
Earlier we declined the strategy of returning SSR data from controller handlers. We also discovered how we can employ nest.js interceptors to pass data to GSSP in a reusable way. Why don't we use interceptors to pass some common initial data to our client? This data may be user data or tokens/translations/configurations/feature flags and so on.
Creating a configuration
In order to pass some configuration to our client we first need to create some configuration. I will not create a separate module and service for accessing configuration in nest.js (but that is how you would do it in a proper application). A simple file will suffice.
Our configuration will be populated with feature flags. As an example we will use blog_link
flag toggling between two link display options.
// ./src/server/config.ts
const CONFIG = {
features: {
blog_link: true,
},
};
export { CONFIG };
Let's create ConfigInterceptor
that would put configuration to returned value and include it in @UseInterceptors
decorator.
Obviously in a real application you might pull feature flags from a separate service or website. Luckily it's fairly easy to access request information in interceptors:
req
andres
HTTP contexts are available in them so you may execute some middlewares prior to executing interceptors.
// ./src/server/config.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { CONFIG } from './config';
@Injectable()
export class ConfigInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
map((data) => ({
...data,
config: CONFIG,
})),
);
}
}
// ./src/server/app.controller.ts
import { ConfigInterceptor } from './config.interceptor';
// ...
@Get('/')
@Render('index')
@UseInterceptors(ParamsInterceptor, ConfigInterceptor)
public home() {
return {};
}
@Get(':id')
@Render('[id]')
@UseInterceptors(ParamsInterceptor, ConfigInterceptor)
public blogPost() {
return {};
}
To make sure our interceptor works we may use console.log(ctx.query)
in GSSP.
It is important not to include sensitive information in
ctx.query
: it is also serialized when generating HTML. Therefore it is also not recommended to usectx.query
to pass large items that are unused on the client. As a workaround usectx.req
to access such information: during SSR it will be populated with your Express (or fastify for that matter)req
from nest.js.
AOP for GSSP
Now we have reusable code to pass information to the client: we should also add some reusable code to parse this information and do something with it. Let's create buildServerSideProps
wrapper for GSSP.
// ./src/client/ssr/buildServerSideProps.ts
import { ParsedUrlQuery } from 'querystring';
import { Config } from 'src/shared/types/config';
import {
GetServerSideProps,
GetServerSidePropsContext,
} from 'src/shared/types/next';
type StaticProps = {
features: Config['features'];
};
type StaticQuery = {
config: Config;
};
const buildServerSideProps = <P, Q extends ParsedUrlQuery = ParsedUrlQuery>(
getServerSideProps: (ctx: GetServerSidePropsContext<Q>) => Promise<P>,
): GetServerSideProps<Partial<StaticProps> & P, Partial<StaticQuery> & Q> => {
return async (ctx) => {
const { features } = ctx.query.config || {};
const props = await getServerSideProps(ctx);
return {
props: {
...props,
features,
},
};
};
};
export { buildServerSideProps };
If you are paying attention you may notice that
buildServerSideProps
recieves not a real GSSP but a simplified version that return just theprops
. This stops you from usingredirect
and other properties in individual GSSPs. To avoid that use a real GSSP as the param tobuildServerSideProps
.
In order to have helpful typings we should have a type for Config
.
// ./src/shared/types/config.ts
export type Config = {
features: Record<string, boolean>;
};
// ./src/server/config.ts
import type { Config } from 'src/shared/types/config';
const CONFIG: Config = {
features: {
blog_link: true,
},
};
Also NEXT.js GetServerSideProps
does not quite suit us - we can't really override Query
type. We will add types of our own.
// ./src/shared/types/next.ts
import {
GetServerSidePropsResult,
GetServerSidePropsContext as GetServerSidePropsContextBase,
} from 'next';
import { ParsedUrlQuery } from 'querystring';
export type GetServerSidePropsContext<Q = ParsedUrlQuery> = Omit<
GetServerSidePropsContextBase,
'query'
> & { query: Q };
export type GetServerSideProps<P, Q = ParsedUrlQuery> = (
ctx: GetServerSidePropsContext<Q>,
) => Promise<GetServerSidePropsResult<P>>;
Update pages to use this new wrapper.
// ./src/pages/index.tsx
export const getServerSideProps = buildServerSideProps<THomeProps>(async () => {
const blogPosts = await fetch('/api/blog-posts');
return { blogPosts };
});
// ./src/pages/[id].tsx
export const getServerSideProps = buildServerSideProps<TBlogProps, TBlogQuery>(
async (ctx) => {
const id = ctx.query.id;
const post = await fetch(`/api/blog-posts/${id}`);
return { post };
},
);
It is once again important not to pass the entire config to your client. It might contain sensitive information like tokens, internal URLs and credentials or just make your payload larger.
Accessing application context
We already can access features
property on our pages. But this might not be a very comfortable way of doing so. We have to utilize prop-drilling to pass it to deeply nested components. To avoid that, use global application context.
// ./src/shared/types/app-data.ts
import { Config } from './config';
export type AppData = {
features: Config['features'];
};
// ./src/client/ssr/appData.ts
import { createContext } from 'react';
import { AppData } from 'src/shared/types/app-data';
const AppDataContext = createContext<AppData>({} as AppData);
export { AppDataContext };
To not repeat application context mounting on every page put this logic in _app.tsx
. I will implement it using a class but it is not a requirement.
// ./src/pages/_app.tsx
import NextApp, { AppProps } from 'next/app';
import { AppDataContext } from 'src/client/ssr/appData';
import { AppData } from 'src/shared/types/app-data';
class App extends NextApp<AppProps> {
appData: AppData;
constructor(props: AppProps) {
super(props);
this.appData = props.pageProps.appData || {};
}
render() {
const { Component, pageProps } = this.props;
return (
<AppDataContext.Provider value={this.appData}>
<Component {...pageProps} />
</AppDataContext.Provider>
);
}
}
export default App;
Update buildServerSideProps
slightly.
// ./src/client/ssr/buildServerSideProps.ts
import { AppData } from 'src/shared/types/app-data';
// ...
type StaticProps = {
appData: Partial<AppData>;
};
// ...
return {
props: {
...props,
appData: {
features,
},
},
};
And create a hook to use AppDataContext
easily.
// ./src/client/ssr/useAppData.ts
import { useContext } from 'react';
import { AppDataContext } from './appData';
const useAppData = () => {
return useContext(AppDataContext);
};
export { useAppData };
Finally, implement the useFeature
hook and utilize it on the index
page.
// ./src/client/hooks/useFeature.ts
import { useAppData } from 'src/client/ssr/useAppData';
const useFeature = (feature: string, defaultValue = false) => {
return useAppData().features[feature] ?? defaultValue;
};
export { useFeature };
// ./src/pages/index.tsx
const Home: FC<THomeProps> = ({ blogPosts }) => {
const linkFeature = useFeature('blog_link');
return (
<div>
<h1>Home</h1>
{blogPosts.map(({ title, id }) => (
<div key={id}>
{linkFeature ? (
<>
{title}
<Link href={`/${id}`}> Link</Link>
</>
) : (
<Link href={`/${id}`}>{title}</Link>
)}
</div>
))}
</div>
);
};
Validate the changes in the browser. localhost:3000
should display links according to the feature flag we set in the server configuration. Update the configuration and check if after page refresh displayed link changes.
Client-side navigation
After makes such significant changes to our SSR pipelines and GSSP we surely have to check if client-side navigations still work.
Our concerns are justified: browsers will reload the page when making client-side transitions. In the terminal we would see an error: we can't quite serialize appData.features
field. It expectedly is undefined
when making client-side navigation. Our nest.js controller handlers do not get called when making client-side transitions, remember?
Only apply interceptors or
req
properties to pass actual initial data. And never expect them to be available in GSSP functions. Such data may be translations, feature flags, CSRF tokens and other configurations.
To solve our current issue let's sanitize buildServerSideProps
return values.
// ./src/client/ssr/filterUnserializable.ts
const filterUnserializable = (
obj: Record<string, unknown>,
filteredValues: unknown[] = [undefined],
): Record<string, unknown> => {
return Object.keys(obj).reduce<Record<string, unknown>>((ret, key) => {
if (typeof obj[key] === 'object' && obj[key] !== null) {
return {
...ret,
[key]: filterUnserializable(obj[key] as Record<string, unknown>),
};
} else if (!filteredValues.includes(obj[key])) {
return { ...ret, [key]: obj[key] };
}
return ret;
}, {});
};
export { filterUnserializable };
// ./src/client/ssr/buildServerSideProps
import { filterUnserializable } from './filterUnserializable';
// ...
return {
props: {
...(await getServerSideProps(ctx)),
appData: filterUnserializable({ features }) as StaticProps['appData'],
},
};
This
filterUnserializable
implementation has some flaws. It will show poor perfomance for large and nested objects. For completeness and consistency between article versions I will leave the code as is to later return in a separate article on how to diagnose and profile such issues.
Let's test client-side transitions once again - should work now.
Having the ability to easily pass arbitary data to your client is one of the primary advantages
nest-next
offers in my opinion. Data may come from any source including middlewares mutatingreq
properties. You may leverage existing Express solutions in yournest-next
project and deliver changes to clients almost immediately.
Subdirectory deployment
In some cases you might want your production application to be deployed in a subdirectory. For example we are working on documentation and our service should be available at /docs
. Or with our current application - a blog section on a website with /blog
prefix.
What should we do to support such functionality? It seems the only thing blocking us is API requests and client links. Statics will be deployed on a CDN so it will not be a problem. Seems like a fairly simple task at first.
But then we remember that NEXT.js makes requests to an internal endpoint when making client-side transitions. Request handlers executes GSSP on the server and return JSON with data. We are rather helpless when trying to change this. And if we do not use a CDN the entire static will break. This will not do for us.
Luckily in NEXT.js docs we find basePath
parameter. It allows NEXT.js to support subdirectory deployments out of the box, adding it to every NEXT.js server endpoint. Great, now we have a plan, let's start.
Development proxy
Before we start coding anything we should provide a way to reproduce our production environment with subdirectory deployment. For this we will use a simple proxy server. Use Docker and nginx to implement and start a proxy. Let's add proxy configuration.
# ./nginx.conf
server {
listen 8080;
location /blog/ {
proxy_pass http://localnode:3000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
It is important to understand that nginx will boot in a Docker container with a net of its own. Therefore to properly proxy the request to the nest.js server we would need an address for the host machine. We will use
localnode
name for it. I have not testedstart:proxy
script on Windows machines. Unless you are using WSL you may have some issues supplying the container with host machine address.
This configuration will allow proxy to handle requests to localhost:8080/blog/*
and proxy them to localhost:3000/*
. In package.json
add start:proxy
script to boot a Docker container with our proxy.
docker run --name nest-next-example-proxy \
-v $(pwd)/nginx.conf:/etc/nginx/conf.d/default.conf:ro \
--add-host localnode:$(ifconfig en0 | grep inet | grep -v inet6 | awk '{print $2}') \
-p 8080:8080 \
-d nginx
Adding basePath
Add basePath
to your server configuration. Update typings and pull data from BASE_PATH
environment variable.
// ./src/shared/types/config.ts
export type Config = {
features: Record<string, boolean>;
basePath: string;
};
// ./src/server/config.ts
import { Config } from 'src/shared/types/config';
const CONFIG: Config = {
features: {
blog_link: true,
},
basePath: process.env.BASE_PATH || '',
};
export { CONFIG };
Create next.config.js
to configure NEXT.js. Put basePath
there as well.
// ./next.config.js
module.exports = {
basePath: process.env.BASE_PATH,
};
Proxy and basePath
Check website on localhost:8000/blog
after booting both server and proxy. The request reaches nest.js server but NEXT.js static requests are unsuccessful. NEXT.js expects that the request comes with the desired basePath
in req.url
. But in our nginx proxy we cut it off. Add a separate rule to proxy /blog/_next
requests without replacing our basePath
.
server {
listen 8080;
location /blog/_next/ {
proxy_pass http://localnode:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
location /blog/ {
proxy_pass http://localnode:3000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
Reboot Docker container and check the website in your browser. Unfortunately we are out of luck again.
There was (and still is to some extent) a problem with
nest-next
. This package applies a filter that forwards unhandled 404s to NEXT.js. But only if the request starts with/_next
. Therefore NEXT.js server expects a request that starts withbasePath
andnest-next
proxies only requests starting with/_next
.
I submitted a PR to fix this issue and it was merged. However package maintainer Kyle has not published a new version with this fix since then. You may ask him to publish or use a compiled version from my GitHub.
yarn upgrade nest-next@https://github.com/yakovlev-alexey/nest-next/tarball/base-path-dist
Tag
base-path-dist
only includes the required files.
After bumping npm package check localhost:8080/blog
in your browsers. You should finally see a familiar Home page. Client-side navigations also work!
Fetch wrapper
The only thing left is to add basePath
to fetch
requests. It seems that there is not need for that at the moment. However that is only due to the fact that we call fetch
only on the server and never from the client. As soon as you start fetching from the client you start seeing errors.
Let's refactor buildServerSideProps
a little. Update typings for AppData
and extract appData
parsing from ctx.query
to a separate method.
// ./src/shared/types/app-data.ts
import { Config } from './config';
export type AppData = Pick<Config, 'basePath' | 'features'>;
// ./src/client/ssr/extractAppData.ts
import { GetServerSidePropsContext } from 'src/shared/types/next';
import { AppData } from 'src/shared/types/app-data';
import { filterUnserializable } from './filterUnserializable';
import { StaticQuery } from './buildServerSideProps';
const extractAppData = (
ctx: GetServerSidePropsContext<Partial<StaticQuery>>,
) => {
const { features, basePath } = ctx.query.config || {};
return filterUnserializable({ features, basePath }) as Partial<AppData>;
};
export { extractAppData };
Use new util in buildServerSideProps
.
// ./src/client/ssr/buildServerSideProps.ts
import { extractAppData } from './extractAppData';
// ...
const buildServerSideProps = <P, Q extends ParsedUrlQuery = ParsedUrlQuery>(
getServerSideProps: (ctx: GetServerSidePropsContext<Q>) => Promise<P>,
): GetServerSideProps<Partial<StaticProps> & P, Partial<StaticQuery> & Q> => {
return async (ctx) => {
const props = await getServerSideProps(ctx);
return {
props: {
...props,
appData: extractAppData(ctx),
},
};
};
};
export { buildServerSideProps };
Finally we may access basePath
on the client. The only thing left is to add this property to the actual fetch
wrapper. I will not create an ingenious solution and turn envAwareFetch
into a function with side effects.
// ./src/shared/utils/fetch.ts
import { isServer, PORT } from '../constants/env';
type FetchContext = {
basePath: string;
};
const context: FetchContext = {
basePath: '',
};
const initializeFetch = (basePath: string) => {
context.basePath = basePath;
};
const getFetchUrl = (url: string) => {
if (isServer) {
// на сервере не нужно добавлять basePath - запрос делается не через proxy
return url.startsWith('/') ? `http://localhost:${PORT}${url}` : url;
}
return url.startsWith('/') ? context.basePath + url : url;
};
const envAwareFetch = (url: string, options?: Partial<RequestInit>) => {
const fetchUrl = getFetchUrl(url);
return fetch(fetchUrl, options).then((res) => res.json());
};
export { envAwareFetch as fetch, initializeFetch };
Finally initialize fetch
using initializeFetch
. It might seem that we should do that in GSSP however it is once again only run on the server. Therefore use _app.tsx
as the place to initialize fetch
.
// ./src/pages/_app.tsx
constructor(props: AppProps) {
super(props);
this.appData = props.pageProps.appData || {};
initializeFetch(this.appData.basePath);
}
To check that everything works properly you add a fetch
request in useEffect
on one of the pages.
This way we can deploy
nest-next
application in subdirectories without loosing any of the features or advantages we had.
Proxy without rewrites
There is also an option to use a proxy without rewrites. Then all requests have to be handled with basePath
on the server as well. Proxy configuration would look like this.
server {
listen 8080;
location /blog/ {
proxy_pass http://localnode:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
To prepend basePath
to every nest.js server endpoint you may employ something like nest-router
and use basePath
as path
parameter.
It is also important to initialize
fetch
before making any requests in GSSP. And you would have to pullbasePath
from an environment variable or directly from config when running GSSP on client-side transition.
Conclusion
nest-next
allows you to have very reusable SSR pipelines with all the features of NEXT.js and existing infrastructure you might have with nest.js and Express. Hopefully this series of articles helped you to better understand how you might leverage this technology to help you develop large applications. Skills you might get from completing this tutorial can also be reused for configuring nest.js with other purposes.
Posted on January 7, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.