This is the first part in a series of articles about combining nest.js and NEXT.js. Today you will learn how to setup a project and choose a valid SSR strategy. In the second part you will learn about Hot Module Replacement, more SSR techniques and subdirectory deployments.
Hey, it's October 2022 and Next.js 13 was recently released! It brings lots of changes and the new
app
directory. While I am working on discovering all the new features and use cases to update this tutorial (and perhapsnest-next
), make sure to leave your feedback onnest-next
with Next.js 13 in this GitHub issue.
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
- Before we start
- Creating a nest.js application
- NEXT.js installation
- SSR data preparation
- Next steps
Introduction
When choosing a framework for web development in 2022 you might be considering NEXT.js. It is a React based framework that supports SSR/SSG, Typescript, great codesplitting and opinionated routing out of the box. It's truly a great choice for solo developers and teams alike.
NEXT.js also offers some backend functionality with serverless functions. However, serverless may not be the way for you and you would like to use something you are more used to. nest.js is a great choice here - it follows MVC architecture familiar to many which makes it very scalable and extendable.
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.
But how do you integrate these two? How do you make them work together? The simplest option would be to create a sophisticated (or not so sophisticated) proxy that would forward API requests to nest.js and all others to NEXT.js. But this might not be an option for you if you are limited by the amount of microservices you can use or you might not want a real monorepo in order to share code between services.
Luckily there is an easy option for you to embed Next.js right in your nest.js server. Meet nest-next
- a view renderer for nest.js which uses NEXT.js to render pages right from a controller with a single decorator. However this leads to a few caveats in the inner workings of your application. Let's create a simple nest-next
application together from the ground up so that we can discover these caveats and I can tell you about some of the best practices I and my colleagues discovered using this technology.
Throughout the article I will try to delve as little as possible into each of the frameworks and will primarily focus on the bridge between the two. When certain knowledge about a framework is needed I will leave links to official documentation.
Before we start
It's highly likely that you or your team opted for NEXT.js before choosing a backend. Here I recommend you to stop temporarily and seriously consider NEXT.js server features - you might not need a real backend for many use cases like a simple backend-of-the-frontend render server. Using two frameworks and maintaining a bridge between them would be an overhead.
You should only consider using
nest-next
if you are either planning a real node.js backend or you already have certain Express/fastify/nest.js infrastructure you plan to employ.
The article is fairly large since it covers most of the quirky details about the frameworks and it has been split into two parts. In the first one we will create a simple application from the ground up and show solutions the very basic problems you might have with SSR. If you consider yourself an experienced developer in this stack it might be more interesting for you to start on the second part where I talk about some of the more advanced use cases like Hot Module Replacement, SSR techniques and deployment in a subdirectory.
And finally: for those who prefer skipping the article and getting right into code - you can find the source code for this article on my GitHub - https://github.com/yakovlev-alexey/nest-next-example - commit history mostly follows the article.
Creating a nest.js application
First we would need a nest.js application as our base. We will use nest.js CLI to generate a template.
npx @nestjs/cli new nest-next-example
Follow the instructions. I had chosen yarn as my package manager and will leave command snippets for yarn in the article but I assume you are familiar with package managers will be fine using npm in this article.
Upon command completion we will get a mostly empty project which may start instantly. I will remove all the test files (test
directory and app.controller.spec.ts
) from the project since we are not going to create any tests in this article.
I also recommend using the following directory structure that resembles a monorepo
└── src
├── client # client code: hooks, components, etc
├── pages # actual NEXT.js pages
├── server # nest.js server code
└── shared # common types, utils, constants etc
Let's make the necessary changes to the nest.js configuration to support our new layout.
// ./nest-cli.json
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"entryFile": "server/main"
}
Now if we start the application using yarn start:dev
we should see "Hello world"
when visiting localhost:3000
in the browser.
Due to certain caveats in nest.js building pipelines you might see an error in your terminal. The error might looks something like this:
'Error: Cannot find module '.../dist/server/main'
. In that case you may temporarily set"entryFile"
to just"main"
- that should solve the issue.
NEXT.js installation
Now let's add NEXT.js to our project.
# NEXT.js and its peers
yarn add next react react-dom
# required types and eslint preset
yarn add -D @types/react @types/react-dom eslint-config-next
Next you should start NEXT.js development server using yarn next dev
. Necessary changes to your tsconfig will be made as well as a (few) new files added including next-env.d.ts
. NEXT.js will boot successfully. However if we now start nest.js server, we will discover that NEXT.js broke our typescript config. Let's create a separate config for nest.js - I will be reusing existing tsconfig.build.json
as tsconfig.server.json
with the following contents.
// ./tsconfig.server.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false
},
"include": [
"./src/server/**/*.ts",
"./src/shared/**/*.ts",
"./@types/**/*.d.ts"
]
}
Now nest.js works again. Let's update scripts section in our package.json
file.
// ./package.json
"scripts": {
"prebuild": "rimraf dist",
"build": "yarn build:next && yarn build:nest",
"build:next": "next build",
"build:nest": "nest build --path ./tsconfig.server.json",
"start": "node ./dist/server/main.js",
"start:next": "next dev",
"start:dev": "nest start --path ./tsconfig.server.json --watch",
"start:debug": "nest start --path ./tsconfig.server.json --debug --watch",
"start:prod": "node dist/main",
// ... lint/format/test etc
},
Let's add an index
page and an App
component to src/pages
directory.
// ./src/pages/app.tsx
import { FC } from 'react';
import { AppProps } from 'next/app';
const App: FC<AppProps> = ({ Component, pageProps }) => {
return <Component {...pageProps} />;
};
export default App;
// ./src/pages/index.tsx
import { FC } from 'react';
const Home: FC = () => {
return <h1>Home</h1>;
};
export default Home;
Now when you start the app using yarn start:next
you should the this page on localhost:3000
.
You should also add
.next
folder to your.gitignore
- that is where NEXT.js stores its builds.
Making acquaintance between the frameworks
Now we have two separate servers. But what we want is a single nest.js server making use of nest-next
: so let's install it.
yarn add nest-next
Next we should initialize the newly installed RenderModule
in app.module.ts
.
// ./src/server/app.module.ts
import { Module } from '@nestjs/common';
import { RenderModule } from 'nest-next';
import Next from 'next';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
/* should pass a NEXT.js server instance
as the argument to `forRootAsync` */
imports: [RenderModule.forRootAsync(Next({}))],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Now we can make use of @Render
decorator exported from nest. So let's create our first page controller in app.controller.ts
.
// ./src/server/app.controller.ts
import { Controller, Get, Render } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@Render('index')
home() {
return {};
}
}
However when we start the app using yarn start:dev
and open the desired page in web browser we will see an error in NEXT.js - the build was not found. Turns out the render server was booted in production mode and expected to see a ready made build of the frontend application. To fix this we should provide dev: true
argument when initialising server instance.
// ./src/server/app.module.ts
imports: [
RenderModule.forRootAsync(Next({ dev: true }))
],
Let's try again. Open localhost:3000
in your browser and you will see a 404. Which is unexpected since we have both a controller and a NEXT.js page. Turns out nest-next
is looking in a wrong folder. By default it uses views
subdirectory in pages
folder rather than the folder itself. I personally do not like this inconsistency with NEXT.js so let's specify viewsDir: null
in our RenderModule
instance.
// ./src/server/app.module.ts
imports: [
RenderModule.forRootAsync(
Next({ dev: true }),
/* null means that nest-next
should look for pages in root dir */
{ viewsDir: null }
)
],
Adjacent to
viewsDir
is anotherdev
option - this time fornest-next
which enables more specific error serialization. I did not find this option useful but it is there for you if you need it.
Finally, when we open localhost:3000
in the browser we see the page we described earlier in index.tsx
.
SSR data preparation
One of the primary NEXT.js advantages is the ability to easily fetch data required to statically or dynamically render a page. Users have a few options to do so. We will use getServerSideProps
(GSSP) - the most up-to-date way of fetching dynamic data in NEXT.js. However nest-next
properly supports other methods as well.
Time to add another page. Let's imagine that our index
is a blog page. And the page we would be creating is a blog post by id.
Add the necessary types and controllers:
// ./src/shared/types/blog-post.ts
export type BlogPost = {
title: string;
id: number;
};
// ./src/server/app.controller.ts
import { Controller, Get, Param, Render } from '@nestjs/common';
// ...
@Get(':id')
@Render('[id]')
public blogPost(@Param('id') id: string) {
return {};
}
Add the new page:
// ./src/pages/[id].tsx
import { GetServerSideProps } from 'next';
import Link from 'next/link';
import { FC } from 'react';
import { BlogPost } from 'src/shared/types/blog-post';
type TBlogProps = {
post: BlogPost;
};
const Blog: FC<TBlogProps> = ({ post = {} }) => {
return (
<div>
<Link href={'/'}>Home</Link>
<h1>Blog {post.title}</h1>
</div>
);
};
export const getServerSideProps: GetServerSideProps<TBlogProps> = async (
ctx,
) => {
return { props: {} };
};
export default Blog;
And refresh our Home page:
// ./src/pages/index.tsx
import { GetServerSideProps } from 'next';
import Link from 'next/link';
import { FC } from 'react';
import { BlogPost } from 'src/shared/types/blog-post';
type THomeProps = {
blogPosts: BlogPost[];
};
const Home: FC<THomeProps> = ({ blogPosts = [] }) => {
return (
<div>
<h1>Home</h1>
{blogPosts.map(({ title, id }) => (
<div key={id}>
<Link href={`/${id}`}>{title}</Link>
</div>
))}
</div>
);
};
export const getServerSideProps: GetServerSideProps<THomeProps> = async (
ctx,
) => {
return { props: {} };
};
export default Home;
Great! Now we have a few pages that need some data. The only thing left is to supply this data. Let's explore the different ways we can do that.
Using nest.js controller
Our home
controller in app.controller.ts
return an empty object. It turns out that everything that happens to be in that object will be accessible in ctx.query
in GSSP.
Let's add some stub data in app.service.ts
.
// ./src/server/app.service.ts
import { Injectable } from '@nestjs/common';
import { from } from 'rxjs';
const BLOG_POSTS = [
{ title: 'Lorem Ipsum', id: 1 },
{ title: 'Dolore Sit', id: 2 },
{ title: 'Ame', id: 3 },
];
@Injectable()
export class AppService {
getBlogPosts() {
return from(BLOG_POSTS);
}
}
In controller we may access this service and return the data.
// ./src/server/app.controller.ts
import { map, toArray } from 'rxjs';
// ...
@Get('/')
@Render('index')
home() {
return this.appService.getBlogPosts().pipe(
toArray(),
map((blogPosts) => ({ blogPosts })),
);
}
Now we will have access to blogPosts
property in ctx.query
in GSSP. However it seems that this implementation is not very reliable: TypeScript should warn us, that there is in fact no blogPosts
in ctx.query
. It is typed to be ParsedUrlQuery
.
Surely TypeScript is wrong? Let's leave a few console.log
s of ctx.query
in our index.tsx
GSSP. Then open localhost:3000
. Check our terminal (that's where the logs would land - GSSP is only run on the server). We indeed see that blogPosts
are there. What's wrong then?
Let's open localhost:3000/1
and then click the Home
link. Suddenly our terminal logs an empty object. But how is that possible, we clearly return blogPosts
property from our controller?
When navigating on the client side NEXT.js fetches an internal endpoint that executes just the GSSP function and returns serialized JSON from it. So our
home
controller is not called at all andctx.query
is populated purely with path params and search query.
Using direct services access
As stated previously, GSSP is only executed on the server. Therefore we could technically use nest.js services directly from inside GSSP.
This is in fact a very bad idea. You either have to construct every service yourself (then you have lots of repeated code and loose any profits from DI) or expose nest.js application with it's get
method.
Still even if you were to bare with the spaghetti facilitated by global application access from different contexts you would end up missing the HTTP contexts when calling services.
By sending requests to itself
Nothing really stops us from making asynchronous request using fetch
from GSSP. However we should make a wrapper around fetch
to choose which address to call. But before we proceed we have to get information about where the code is executed and what port the server is subscribed to.
// ./src/shared/constants/env.ts
export const isServer = typeof window === 'undefined';
export const isClient = !isServer;
export const NODE_ENV = process.env.NODE_ENV;
export const PORT = process.env.PORT || 3000;
Now update port subscription in main.ts
(await app.listen(PORT)
) and choose NEXT.js mode depending on environment.
// ./src/server/app.module.ts
RenderModule.forRootAsync(
Next({ dev: NODE_ENV === 'development' }),
{ viewsDir: null }
)
// ./package.json
"start:dev": "NODE_ENV=development nest start --path ./tsconfig.server.json --watch"
Now that server imports modules from
src/shared
the structure of compiled nest.js server differs from before. If you previously changedentryFile
innest-cli.json
return it to the old value (server/main.ts
), cleandist
folder and reboot the server.
Wrapper for fetch
Now we can add a wrapper for fetch
to choose hostname depending on execution environment.
// ./src/shared/utils/fetch.ts
import { isServer, PORT } from '../constants/env';
const envAwareFetch = (url: string, options?: Record<string, unknown>) => {
const fetchUrl =
isServer && url.startsWith('/') ? `http://localhost:${PORT}${url}` : url;
return fetch(fetchUrl, options).then((res) => res.json());
};
export { envAwareFetch as fetch };
And update app.service.ts
.
// ./src/server/app.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { from, of, toArray } from 'rxjs';
const BLOG_POSTS = [
{ title: 'Lorem Ipsum', id: 1 },
{ title: 'Dolore Sit', id: 2 },
{ title: 'Ame', id: 3 },
];
@Injectable()
export class AppService {
getBlogPosts() {
return from(BLOG_POSTS).pipe(toArray());
}
getBlogPost(postId: number) {
const blogPost = BLOG_POSTS.find(({ id }) => id === postId);
if (!blogPost) {
throw new NotFoundException();
}
return of(blogPost);
}
}
Add new API endpoints to app.controller.ts
.
// ./src/server/app.controller.ts
import { Controller, Get, Param, ParseIntPipe, Render } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('/')
@Render('index')
home() {
return {};
}
@Get(':id')
@Render('[id]')
public blogPost(@Param('id') id: string) {
return {};
}
@Get('/api/blog-posts')
public listBlogPosts() {
return this.appService.getBlogPosts();
}
@Get('/api/blog-posts/:id')
public getBlogPostById(@Param('id', new ParseIntPipe()) id: number) {
return this.appService.getBlogPost(id);
}
}
Finally let's update GSSP methods.
// ./src/pages/index.tsx
import { fetch } from 'src/shared/utils/fetch';
export const getServerSideProps: GetServerSideProps<THomeProps> = async () => {
const blogPosts = await fetch('/api/blog-posts');
return { props: { blogPosts } };
};
// ./src/pages/[id].tsx
import { fetch } from 'src/shared/utils/fetch';
export const getServerSideProps: GetServerSideProps<TBlogProps> = async () => {
const id = ctx.query.id;
const post = await fetch(`/api/blog-posts/${id}`);
return { props: { post } };
};
Visit localhost:3000
. Indeed the blog list is available. Let's visit one of the links to a post - everything should work here as well, client side navigation is fine.
However when we update the page on a post page we get an error - there is no such blog post. Everything seemed to work with client side navigation.
As we already discovered
nest-next
puts controller return value toctx.query
. This means that the actual query is not there and it the responsibility of the user to prepare it.
To fix this issue we will return the id from blogPost
controller.
// ./src/server/app.controller.ts
@Get(':id')
@Render('[id]')
public blogPost(@Param('id') id: string) {
return { id };
}
API endpoint casted the parameter to an integer. In this case it's better to not parse parameters to stay consistent with NEXT.js and keep them as strings.
Now let's refresh the page in the browser - this should have solved our issue.
Passing path parameters
Obviously we are at a terrible situation when we need to manually pass all the parameters in controllers. What if we need to use search parameters? Surely there is a way to fix that?
We will use a snippet of AOP (Aspect-oriented programming) and one of its mechanisms in nest.js: an Interceptor.
// ./src/server/params.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class ParamsInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest() as Request;
/* after executing the handler add missing request query and params */
return next.handle().pipe(
map((data) => {
return {
...request.query,
...request.params,
...data,
};
}),
);
}
}
According to NEXT.js documentation path parameters take priority over search params. We also will not override the data from our handler.
Decorate page controller handlers with new interceptor.
// ./src/server/app.controller.ts
import { UseInterceptors } from '@nestjs/common';
import { ParamsInterceptor } from './params.interceptor';
// ...
@Get('/')
@Render('index')
@UseInterceptors(ParamsInterceptor)
public home() {
return {};
}
@Get(':id')
@Render('[id]')
@UseInterceptors(ParamsInterceptor)
public blogPost() {
return {};
}
It is important that path parameters have the same names in nest.js and NEXT.js. In other words path params in @Get
and in @Render
should be the same. API endpoints must not have this interceptor - we do not want to return path params back when calling API.
It would be wise to separate API and page controllers. Then we would be able to put
@UseInterceptors
decorator on the entire controller class. In this article for simplicity purposes API and page controllers are merged.
Let's validate our changes in the browser by refreshing the page. We should still properly see a blog post by id.
Next steps
At this point we have a basic nest-next
application capable of rendering pages and supplying them with data. However we are yet to capitalise on some of the real advantages of such a setup. There are also some other quirks you might encounter especially using this combo for enterprise development.
To learn more advanced topics like HMR, more SSR techniques and proxying with nest-next
read the second part of this article.
At this moment I hope this article helped you to finally try out those frameworks together with great efficiency despite the scarce info on actual nest-next
usage in docs.
Top comments (11)
I encountered one big issue in end result. When I refreshed page for certain blog post it threw error.
As far as I can tell it was unable to resolve [id] to specific value in url.
Facing same issue, In Next js v11 it is working fine but this issue is faced in v12 of NextJs. Have you fixed this or have any more idea about it ?
After long wait the fix is out - it was tracked in this issue github.com/kyle-mccarthy/nest-next...
Not sure if package owner updated the version yet
Is there a way to combine next + nest with the new next v13 app/ folder ?
I collected a few of my observations in this GH issue - github.com/kyle-mccarthy/nest-next.... If you have any issues using it I think you should add a comment to the thread there
is this error
ERROR [ExceptionHandler] Cannot read properties of undefined (reading 'nextConfig')
somehow related to the NEXT version? I got this error going from the beginning until "SSR data preparation", where it expected to work? While NextJS is rendering fine, nest doesn't want to start.I saw this same issue as well, is there a solution? I tried disabling the eslint and have seen an error on the package.json about failing to write because they would overwrite, this went away after running build:nest, although the package.json still shows 9 errors.
Attempting to run build:next exposed another issue with fetch.ts not being allowed to have comments in children (though there are no comments in fetch.ts)
getting the same error did you find any solution?
Finally I made a solution patch. Keep this in /patches/nest-next@10.1.0.patch file. patches should be located out of src. After then, run install command of your package manager(npm i, pnpm i, ..)
(10.1.0 means the last version of nest-next, which i used here, was 10.1.0)
What this patch do is so simple. Instead of using next.server, we should use next.renderServer.server. I think class hierarchy was changed as Next version goes up to v13.
I highly recommend that you make your own patch using the functionality of package manager(npm, yarn. pnpm). It's so easy.
What if I wanted to add in another react application say like an inventory which is just needed for internal purposes?
@yakovlev_alexey
I tried to use global.css in the app.tsx but it is not working.
do you have any solution?