Это первая часть статьи о применении комбинации технологий nest.js и NEXT.js, где будет рассмотрено создание проекта, подключение фреймворков и выбор эффективного способа работы с SSR. Во второй части я расскажу о HMR, использовании данных при SSR и разворачивании "за слешом".
Возникает вопрос - а что использовать для бэкенда/фронтбэка? Отличным вариантом будет nest - прогрессивный node.js фреймворк, который позволяет писать достаточно производительные и легко поддерживаемые бэкенды. Сильным его преимуществом является знакомая по Angular/Spring MVC модель архитектуры, основанная на DI.
А как связать эти две сущности? Есть вариант запускать два сервера и настраивать хитрый proxy-сервер, который будет распределять запросы между ними, но такой способ очевидно имеет недостатки: необходимость поднимать несколько сервисов/контейнеров и сложная конфигурация прокси.
К счастью, существует специальный пакет nest-next, который позволяет запускать NEXT.js внутри nest, рендерить страницы на запросы и т.д. Но при такой работе возникает ряд нюансов и неприятностей, решения которых не всегда очевидны. Давайте вместе создадим простой проект на nest-next, по ходу чего я расскажу о главных особенностях данных технологий и поделюсь теми best practices, которые были обнаружены мною и моими коллегами за почти год разработки в таком режиме.
И последнее: для желающих сразу же взглянуть на готовый код прикладываю ссылку на проект на GitHub - https://github.com/yakovlev-alexey/nest-next-example - последовательность коммитов в целом совпадает с ходом дилогии статей.
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 в браузере.
# 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",
"startrod": "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.
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 }
)
],
// ./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;
Отлично, теперь у нас есть страницы, которым нужны данные. Осталось эти данные им отправить. Рассмотрим разные варианты, которые мы можем использовать.
Замокаем блогпосты в 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 там не окажется.
// ./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"
Теперь мы можем добавить обертку для выбора адреса запроса в 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 };
}
Прокидывание параметров запроса
Прописывать все параметры вручную в каждом контроллере неудобно - можно забыть, особенно если это касается 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,
};
}),
);
}
}
// ./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.
Вступление
Когда речь заходит о выборе фреймворка для разработки вебсайта в 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 как раз это и умеет, не имеет смысла тянуть отдельный сервер и заниматься оптимизацией совместной работы двух сущностей.Статья была разделена на 2 части: в этой, первой части я расскажу про создание проекта на nest-next с нуля и его настройке, решению самых первых проблем SSR. Если вы считаете себя уже достаточно опытным разработчиком в этом стеке, то, возможно, стоит начать сразу со второй части, где я рассказываю о HMR, использовании данных при SSR и разворачивании "за слэшом".Следует рассматривать симбиоз nest-next только в тех случаях, когда требуется либо полноценный бэкенд на node.js, который не хочется выносить в отдельный сервис, либо достаточно серьезный фронтбэк-сервер с существенными обязанностями и/или завязкой под имеющуюся инфраструктуру на Express/nest.
И последнее: для желающих сразу же взглянуть на готовый код прикладываю ссылку на проект на 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",
"startrod": "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 }
)
],
Теперь по адресу localhost:3000 мы наконец обнаружим нашу страницу, описанную в index.tsx.Рядом с viewsDir есть еще одна опция dev уже для nest-next, которая отвечает уже за сериализацию ошибок перед отправкой их в NEXT.js - в production окружениях мы можем не хотеть отправлять подробную информацию об ошибке.
Запрос данных для 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"
Обертка над fetchТеперь сервер импортирует модули из src/shared и структура скомпилированного сервера nest выглядит иначе: main.ts переехал в dist/server, как и следовало изначально. Если вы меняли entryFile в nest-cli.json, то нужно вернуть изначально запланированное значение и перезапустить сервер, а папку dist очистить, иначе сервер будет запускаться на старой версии сборки.
Теперь мы можем добавить обертку для выбора адреса запроса в 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.
NEST-NEXT: Best Practices — Часть 1
Это первая часть статьи о применении комбинации технологий nest.js и NEXT.js, где будет рассмотрено создание проекта, подключение фреймворков и выбор эффективного способа работы с SSR. Во второй части...
habr.com