Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
January 5, 2022 01:38 pm GMT

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 (that is coming soon) you will learn about Hot Module Replacement, more SSR techniques and 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

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 (which is coming soon) 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 peersyarn add next react react-dom# required types and eslint presetyarn 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.tsximport { FC } from 'react';import { AppProps } from 'next/app';const App: FC<AppProps> = ({ Component, pageProps }) => {    return <Component {...pageProps} />;};export default App;
// ./src/pages/index.tsximport { 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.tsimport { 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.tsimport { 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.tsimports: [    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.tsimports: [    RenderModule.forRootAsync(        Next({ dev: true }),        /* null means that nest-next             should look for pages in root dir */        { viewsDir: null }    )],

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.tsexport type BlogPost = {    title: string;    id: number;};// ./src/server/app.controller.tsimport { 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].tsximport { 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.tsximport { 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.tsimport { 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.tsimport { 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.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.tsexport 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.tsRenderModule.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 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.tsimport { 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.tsimport { 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.tsimport { 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.tsximport { 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].tsximport { 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 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 };}

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.tsimport {    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.tsimport { 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.

Short slightly artistic summary

Stay tuned to the second part where you will learn about HMR, more caveats with SSR and subdirectory deployments.

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.


Original Link: https://dev.to/yakovlev_alexey/creating-a-project-with-nestjs-nextjs-3i1i

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To