DEV Community

Cover image for Creating a Project with Nest.js + Next.js
Alexey Yakovlev
Alexey Yakovlev

Posted on • Updated on

Creating a Project with Nest.js + Next.js

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 perhaps nest-next), make sure to leave your feedback on nest-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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    ]
}
Enter fullscreen mode Exit fullscreen mode

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
},
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
// ./src/pages/index.tsx
import { FC } from 'react';

const Home: FC = () => {
    return <h1>Home</h1>;
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {};
    }
}
Enter fullscreen mode Exit fullscreen mode

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 }))
],
Enter fullscreen mode Exit fullscreen mode

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 }
    )
],
Enter fullscreen mode Exit fullscreen mode

Adjacent to viewsDir is another dev option - this time for nest-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 {};
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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 })),
    );
}
Enter fullscreen mode Exit fullscreen mode

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.logs 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 and ctx.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;
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Now that server imports modules from src/shared the structure of compiled nest.js server differs from before. If you previously changed entryFile in nest-cli.json return it to the old value (server/main.ts), clean dist 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 };
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 } };
};
Enter fullscreen mode Exit fullscreen mode

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 to ctx.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 };
}
Enter fullscreen mode Exit fullscreen mode

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,
                };
            }),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {};
}
Enter fullscreen mode Exit fullscreen mode

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.

Short slightly artistic summary

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.

Latest comments (10)

Collapse
 
bluesky49 profile image
bluesky49 • Edited

@yakovlev_alexey
I tried to use global.css in the app.tsx but it is not working.
do you have any solution?

Collapse
 
liohau profile image
Julien

Is there a way to combine next + nest with the new next v13 app/ folder ?

Collapse
 
litehacker profile image
Giorgi Gvimradze • Edited

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.

Collapse
 
vedantkirve123 profile image
vedant

getting the same error did you find any solution?

Collapse
 
mavstronaut profile image
Maverick

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)

Collapse
 
yakovlev_alexey profile image
Alexey Yakovlev • Edited

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

Collapse
 
iamjimparsons profile image
iamjimparsons

What if I wanted to add in another react application say like an inventory which is just needed for internal purposes?

Collapse
 
jeserkin profile image
Eugene Serkin • Edited

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.

error

Collapse
 
mouryasumit0 profile image
Sumit Mourya

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 ?

Collapse
 
yakovlev_alexey profile image
Alexey Yakovlev

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