NEST-NEXT: Best Practices — Часть 1
Это первая часть статьи о применении комбинации технологий nest.js и NEXT.js, где будет рассмотрено создание проекта, подключение фреймворков и выбор эффективного способа работы с SSR. Во второй части я расскажу о HMR, использовании данных при SSR и разворачивании "за слешом".
Наступил Октябрь 2022 и вышла новая 13 версия NEXT.js. В ней очень много важных изменений, которые сильно меняют то, как можно использовать nest.js и NEXT.js вместе. Пока я изучаю работу с новой директорией
app
и готовлю обновление для этого туториала, предлагаю вам тоже оставлять ваш фидбек по использованию NEXT.js 13 с библиотекойnest-next
(про которую речь пойдет дальше) в этом GitHub issue.
Оглавление
Вступление
Когда речь заходит о выборе фреймворка для разработки вебсайта в 2021 году, обязательно всплывает NEXT.js - основанный на React фреймворк из коробки позволяет поддерживать SSR/SSG, TypeScript, code-splitting, раутинг и имеет прочие возможности. Но это в первую очередь клиентский фреймворк, который также имеет простенький сервер.
Возникает вопрос - а что использовать для бэкенда/фронтбэка? Отличным вариантом будет nest - прогрессивный node.js фреймворк, который позволяет писать достаточно производительные и легко поддерживаемые бэкенды. Сильным его преимуществом является знакомая по Angular/Spring MVC модель архитектуры, основанная на DI.
А как связать эти две сущности? Есть вариант запускать два сервера и настраивать хитрый proxy-сервер, который будет распределять запросы между ними, но такой способ очевидно имеет недостатки: необходимость поднимать несколько сервисов/контейнеров и сложная конфигурация прокси.
К счастью, существует специальный пакет nest-next, который позволяет запускать NEXT.js внутри nest, рендерить страницы на запросы и т.д. Но при такой работе возникает ряд нюансов и неприятностей, решения которых не всегда очевидны. Давайте вместе создадим простой проект на nest-next, по ходу чего я расскажу о главных особенностях данных технологий и поделюсь теми best practices, которые были обнаружены мною и моими коллегами за почти год разработки в таком режиме.
По ходу повествования я постараюсь как можно меньше вдаваться в подробности каждого из фреймворков, фокусируясь прежде всего на провязку между ними. Там, где будут требоваться определенные знания об одном из фреймворков, я оставлю ссылки на официальную документацию.
Прежде чем мы начнем
Скорее всего, решение использовать NEXT.js будет принято первым, а потом уже команда или одиночный разработчик задумаются о выборе бэкенда или фронтбэка. Здесь следует остановиться и всерьез рассмотреть имеющиеся возможности NEXT.js. Если планируется создавать простой фронтбэк, который будет рендерить страницы клиенту и проксировать запросы в бэкенд, то сервер NEXT.js как раз это и умеет, не имеет смысла тянуть отдельный сервер и заниматься оптимизацией совместной работы двух сущностей.
Следует рассматривать симбиоз nest-next только в тех случаях, когда требуется либо полноценный бэкенд на node.js, который не хочется выносить в отдельный сервис, либо достаточно серьезный фронтбэк-сервер с существенными обязанностями и/или завязкой под имеющуюся инфраструктуру на Express/nest.
Статья была разделена на 2 части: в этой, первой части я расскажу про создание проекта на nest-next с нуля и его настройке, решению самых первых проблем SSR. Если вы считаете себя уже достаточно опытным разработчиком в этом стеке, то, возможно, стоит начать сразу со второй части, где я рассказываю о HMR, использовании данных при SSR и разворачивании "за слэшом".
И последнее: для желающих сразу же взглянуть на готовый код прикладываю ссылку на проект на GitHub - https://github.com/yakovlev-alexey/nest-next-example - последовательность коммитов в целом совпадает с ходом дилогии статей.
Создание приложения nest
Первым делом создадим базовое приложение nest в терминале. Для этого воспользуемся утилитой @nestjs/cli, которая позволяет генерировать шаблонные проекты:
npx @nestjs/cli new nest-next-example
Проследуем указаниям генератора. Я выбрал yarn в качестве менеджера пакетов и далее в статье буду приводить сниппеты для yarn, для npm они будут аналогичными.
В результате выполнения мы получим стартовый проект, который сразу можно запустить. Предлагаю удалить файлы для тестирования (директория test
в корне проекта и файл app.controller.spec.ts
- в рамках данной статьи мы не будем писать тесты.
Для удобства будем использовать следующую архитектуру папки src
:
└── src
├── client # клиентский код: хуки, компоненты и т.д.
├── pages # экраны вебсайта (NEXT.js)
├── server # сервер nest.js
└── shared # общие типы/хелперы и т.д.
Отразим эти изменения в конфигурации nest:
// ./nest-cli.json
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"entryFile": "server/main"
}
Теперь мы можем запустить сервер командой yarn start:dev
и увидеть наш "Hello world"
по адресу localhost:3000
в браузере.
Из-за особенностей сборки nest, при запуске может появиться ошибка
Error: Cannot find module '.../dist/server/main'
- в таком случае временно можно сделать"entryFile": "main"
.
Установка NEXT.js
Теперь добавим в проект NEXT.js:
# NEXT.js и его peer зависимости
yarn add next react react-dom
# необходимые типы и конфиг для eslint
yarn add -D @types/react @types/react-dom eslint-config-next
Запустим сервер разработки NEXT.js: yarn next dev
. В наш проект будут внесены следующие необходимые изменения: добавлен файл next-env.d.ts
и изменен tsconfig.json
. Сервер будет успешно запущен, но когда мы попытаемся снова запустить nest, обнаружим, что изменения конфигурации TypeScript сломали сборку. Переиспользуем имеющийся tsconfig.build.json
в качестве tsconfig.server.json
и поместим туда следующее:
// ./tsconfig.server.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false
},
"include": [
"./src/server/**/*.ts",
"./src/shared/**/*.ts",
"./@types/**/*.d.ts"
]
}
Теперь сервер вновь заработает, когда мы подключим корректный конфиг Typescript. Для этого обновим скрипты в package.json
, чтобы обеспечить поддержку обоих фреймворков:
// ./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 etc)
},
Добавим в каталог src/pages
экран index
и компонент App
, необходимый для работы NEXT.js:
// ./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;
Запустив сервер NEXT.js командой yarn start:next
, мы сможем увидеть эти страницы по адресу localhost:3000
.
В
.gitignore
следует добавить.next
- туда будут попадать результаты сборок NEXT.js
Подружим фреймворки
Теперь у нас есть два отдельных сервера, но нашей целью было подключить NEXT.js в nest. Для этого установим пакет nest-next:
yarn add nest-next
Для корректной работы библиотеки нам следует подключить RenderModule
в 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({
/* в метод инициализации следует
передать инстанс сервера NEXT.js */
imports: [RenderModule.forRootAsync(Next({}))],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Теперь нам доступен декоратор @Render
, который позволяет рендерить страницы подключаемым рендер-модулем nest. Обновим контроллер 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 {};
}
}
Запустим сервер nest: yarn start:dev
. При попытке зайти на страницу, мы увидим ошибку при создании инстанса NEXT.js - собранный бандл не был обнаружен - сервер запустился в продакшен режиме. Для корректной работы нам нужно передать в инстанс NEXT.js опцию dev: true
:
// ./src/server/app.module.ts
imports: [
RenderModule.forRootAsync(Next({ dev: true }))
],
Теперь попробуем зайти на localhost:3000
. Мы увидим стандартную страницу 404 NEXT.js - странно, ведь у нас есть файл index.tsx
в src/pages
и мы видели его при запуске отдельного сервера NEXT.js. На самом деле nest-next по умолчанию ищет страницы в директории views
внутри pages
, чтобы изменить этот параметр, передадим опцию viewsDir
в метод инициализации RenderModule
:
// ./src/server/app.module.ts
imports: [
RenderModule.forRootAsync(
Next({ dev: true }),
/* значение null сообщает nest-next
искать экраны в корне pages */
{ viewsDir: null }
)
],
Рядом с
viewsDir
есть еще одна опцияdev
уже для nest-next, которая отвечает уже за сериализацию ошибок перед отправкой их в NEXT.js - в production окружениях мы можем не хотеть отправлять подробную информацию об ошибке.
Теперь по адресу localhost:3000
мы наконец обнаружим нашу страницу, описанную в index.tsx
.
Запрос данных для SSR
Одно из главных преимуществ NEXT.js - возможность удобно запрашивать данные для SSR. Для этого у нас есть сразу несколько методов, мы будем использовать getServerSideProps (далее - GSSP) - для рендеринга страницы во время обработки запроса - классический SSR. При этом nest-next корректно поддерживает другие виды генерации страниц, предоставляемые NEXT.js. Первым делом добавим еще одну страницу. Предположим, что главная страница - список всех записей в блоге, и добавим страницу блогпоста по id. На нее добавим ссылку на главную страницу.
// ./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 {};
}
// ./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;
// ./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;
Отлично, теперь у нас есть страницы, которым нужны данные. Осталось эти данные им отправить. Рассмотрим разные варианты, которые мы можем использовать.
Через контроллер nest
Наш контроллер home
в app.controller.ts
возвращает пустой объект - все, что мы отправим отсюда, попадет в ctx.query
в GSSP.
Замокаем блогпосты в 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);
}
}
В контроллере обратимся к сервису и вернем блогпосты из контроллера.
// ./src/server/app.controller.ts
import { map, toArray } from 'rxjs';
// ...
@Get('/')
@Render('index')
home() {
return this.appService.getBlogPosts().pipe(
toArray(),
map((blogPosts) => ({ blogPosts })),
);
}
Теперь в GSSP мы сможем получить доступ к blogPosts
через ctx.query
. Очевидно, такой вариант крайне неудобен - тайпинги query подсказывают нам, что нет там никакого blogPosts
, а есть только параметры запроса ParsedUrlQuery
. Дейстивтельно, попробуем зайти на страницу localhost:3000/1
, например, и осуществить переход по ссылке на Home - мы обнаружим, что blogPosts
там не окажется.
При переходах NEXT.js обращается к внутреннему эндопинту, который на сервере исполняет GSSP и возвращает JSON - в таком случае наш контроллер вовсе не исполняется, а в
ctx.query
попадают только параметры запроса из адреса иlocation.search
.
Через прямое обращение к сервисам
Как известно, GSSP исполняется только на сервере, следовательно, мы могли бы обращаться к сервисам nest прямо из тела функции. В действительности этот вариант так же нас совершенно не устраивает - нам нужно самостоятельно создавать инстансы сервисов, про сервисы-синглтоны, DI и вообще все преимущества nest можно забыть. Если же мы попробуем обратиться к сервисам через инстанс приложения, мы натолкнемся на схожую проблему при переходах - сервисам будет не хватать контекста (например, объекта req
) для корректной работы.
Через обращение сервера к самому себе
Нам ничего не мешает (и даже подталкивает) делать асинхронные запросы с помощью fetch
из GSSP. В таком случае, нам нужна специальная обертка над fetch
, которая будет выбирать адрес для запроса в зависимости от среды исполнения. Но прежде чем мы двинемся дальше, нам необходимо получить информацию о среде исполнения сервера - как минимум нам необходим порт, на который подписан сервер.
// ./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;
Обновим подписку на порт в main.ts
(await app.listen(PORT)
), а также определим режим работы для NEXT в зависимости от среды исполнения:
// ./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"
Теперь сервер импортирует модули из
src/shared
и структура скомпилированного сервера nest выглядит иначе:main.ts
переехал вdist/server
, как и следовало изначально. Если вы менялиentryFile
вnest-cli.json
, то нужно вернуть изначально запланированное значение и перезапустить сервер, а папкуdist
очистить, иначе сервер будет запускаться на старой версии сборки.
Обертка над fetch
Теперь мы можем добавить обертку для выбора адреса запроса в fetch
в зависимости от среды исполнения - на сервере мы будем отсылать запрос самим себе:
// ./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 };
Обновим сервис 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);
}
}
Добавим эндпоинты API для запроса блогпостов в наш контроллер:
// ./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);
}
}
Отлично, теперь мы можем обновить методы GSSP в наших экранах:
// ./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 } };
};
Зайдем на localhost:3000
и увидим, что список блогпостов действительно подгрузился. Перейдем по ссылке на один из постов - здесь тоже все работает, как нужно, мы видим корректное название. Но попробуем обновить страницу: теперь мы видим ошибку - такой блогпост не был найден. Странно, ведь при переходе все работало.
На самом деле, как мы уже выяснили, при SSR nest-next подкладывает в ctx.query
возвращаемое значение контроллера, соответственно, параметров запроса там не оказывается. Решим это недоразумение - вернем нужный параметр из контроллера.
// ./src/server/app.controller.ts
@Get(':id')
@Render('[id]')
public blogPost(@Param('id') id: string) {
return { id };
}
Для эндпоинта API мы парсили передаваемый параметр в Int - здесь нет в этом необходимости, ведь это внесет больше путаницы: NEXT.js не преобразует параметры, когда помещает их
ctx.query
, не будем и мы.
Теперь обновим страницу в браузере - как и ожидалось, это решило нашу проблему.
Прокидывание параметров запроса
Прописывать все параметры вручную в каждом контроллере неудобно - можно забыть, особенно если это касается location.search
. Для избежания повторения таких конструкций обратимся к AOP (Aspect-oriented programming) и механизму его реализации при обработке запросов в nest: концепту 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;
/* после выполнения обработчика запроса подложим
возвращаемому значению параметры запроса */
return next.handle().pipe(
map((data) => {
return {
...request.query,
...request.params,
...data,
};
}),
);
}
}
В соответствии с документацией NEXT.js, параметры запроса имеют приоритет по сравнению с параметрами query. Так же не будем перезаписывать поля из возвращаемого значения контроллера, оставляя возможность обработать параметры в контроллере вручную.
Подключим наш интерсептор на обработчики запросов страниц:
// ./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 {};
}
Важно помнить, что названия переменных в адресе должны совпадать в nest и NEXT.js. Иными словами, названия переменных в декораторах @Get()
и @Render()
должны совпадать. На обработчики API, разумеется, вешать этот интерсептор не требуется - мы не хотим, чтобы в возвращаемый JSON попадали параметры запроса.
Хорошим паттерном при разработке nest-next будет разделять контроллеры страниц и API - тогда можно будет повесить декоратор
@UseInterceptors()
прямо на весь класс контроллера. В рамках данной статьи для простоты примера контроллеры не были разделены.
Проверим работу интерсептора в браузере: вновь обновим страницу - интерсептор успешно работает.
Заключение
Уже сейчас приложение позволяет удовлетворить большинство потребностей при разработки простого вебсайта. Но есть еще несколько способов выжать пользу из nest-next, а также ряд нюансов, которые могут встретиться, особенно в энтерпрайзе. Про них читайте во второй части.
Надеюсь, что данная статья помогла желающим попробовать эти фреймворки вместе войти в разработку с максимальной эффективностью, даже несмотря на почти полное отсутствие официальной документации по nest-next.