NextJS API + React Query + Zod

Одним из преимуществ NextJS является возможность совмещать frontend и backend в рамках одного проекта и использовать общие типы и интерфейсы. К сожалению, серверная часть фреймворка работает в отрыве от фронта, являясь во многом самостоятельным приложением. Поэтому, обеспечение typesafe требует дополнительной работы. В этой статья я поделюсь своим опытом решения данного вопроса.

Организация папок

Для каждой сущности заводится отдельная папка, внутри которой прописываются все связанные с ней методы.

│ ├── database │ ├── items │ │ ├── api │ │ │ ├── get-item-by-id │ │ │ │ ├── getItemByIdClient.ts │ │ │ │ ├── getItemByIdServer.ts │ │ │ │ ├── config.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── lib │ │ │ ├── getItemById.ts │ │ │ └── index.ts │ │ ├── hooks │ │ │ ├── use-get-item-by-id │ │ │ │ ├── queryKey.ts │ │ │ │ ├── useGetItemById.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── items.types.ts │ │ └── index.ts │ └── index.ts

Пройдемся по этим уровням, начиная от базового.

items.types.ts

Основой файл. Здесь прописываются типы, связанные с данной сущностью: в каком виде они хранятся в базе, в каком виде данные поступают из базы данных, необходимые поля для создания или редактирования.

Для определения структуры использется zod. В моем последнем проекте использовался Strapi, поэтому выглядело все примерно так:

// в каком виде данные хранятся в БД export type DbItem = z.infer<typeof zDbItem>; export const zDbItem = z.object({ id: z.number().int().positive(), name: z.string().nonempty({ message: "City name cannot be empty" }), sku: z.string().optional().or(z.null()), description: z.string().optional().or(z.null()), }); // в какоми виде данные возвращяются из базы при запросе export type DbItemQuery = z.infer<typeof zDbItemQuery>; export const zDbItemQuery = zDbItem.pick({ id: true }).merge( z.object({ attributes: zDbItem.omit({ id: true, ...omitDates }) }) ); // если необходимо, то здесь же можно прописать объект для наполнения ответа export const itemQueryPopulate = { brand: { fields: ["id", "name"] }, manufacturer: { fields: ["id", "name", "rating"] }, }

/lib - взимодействие с базой данных

В эту папку прописываются все методы, которые будут использоваться далее в коде. Это единственное место, где происходит взаимодействие с базой напрямую.

В файлах методов прописываются схемы zod и ожидаемого ответа. Например:

// .../lib/getItemById.ts export type GetProps = z.infer<typeof zGetProps>; export const zGetProps = z.object({ id: z.coerce.number().int().positive(), }); export type GetResult = z.infer<typeof zGetResult>; export const zGetResult = zDbItemQuery; export const getProductById = async (props: GetProps): Promise<GetResult> => { const {id} = props; .... return item }

Теперь у нас есть изолированные в отдельной папке методы для работой с БД. Если что-то поменяется в ее логике или нужно будет оптимизировать какой-нибудь запрос, то это можно будет сделать именно здесь, сохранив типы аргументов и ответа.

/api - связка frontend и backend

Функции, размещенные в этой папке, отвечают выполнение бизнес-логики приложения. Каждая такая фича изолирована в отдельной папке и содержит как функцию, которая будет вызываться на стороне frontend (getItemByIdClient), так и функцию, которая будет выполнять работу на сервере (getItemByIdServer).

api ├── get-item-by-id ├── getItemByIdClient.ts ├── getItemByIdServer.ts ├── config.ts └── index.ts

Главным же здесь является файл config.ts содержащий в себе информацию об используемых функциями типах и интерфейсах, а также о пути к api route, который будет вызываться со стороны frontend.

// .../api/get-item-by-id/config.ts import { z } from "zod"; import { paths } from "configs"; import { zGetResult, zGetProps } from "../../lib/getItemById"; // path export const PATH = paths.api.items.root; // type Props export type TPropsServer = z.infer<typeof zPropsServer>; export const zPropsServer = zGetProps; export type TPropsClient = z.infer<typeof zPropsClient>; export const zPropsClient = zPropsServer; export type TResult = z.infer<typeof zGetResult>;

По сути это набор re-exports нужный для того, чтобы упростить организацию кода.

Аргументы для Client и Server в данном примере одинаковы, но могут различаться, если, например, для выполнения Server-функции требуется еще id пользователя. Он будет получен из cookies в файле route.ts и передан в функцию getItemByIdServer.

Да. Необходимая часть кода находится в другой папке. Пока приходится организовывать все таким образом. Когда Server Actions перестанут находится в статусе экспериментальных, от этого звена можно бдует избавиться. Также, я еще не пробовал библиотеку ts-rest, котора нацелена на эту же проблему.

Файл выглядит примерно так:

// app/api/items/[id]/route.ts export async function GET(req: NextRequest) { const { pathname } = new URL(req.url); const id = pathname .split("/") .filter(part => part !== "") .at(-1); const result = await getItemByIdServer({ id }); return NextResponse.json(result, { status: result.statusCode }); }

Функция getItemByIdServer возвращает не только ответ, но и Http-код ответа:

// getItemByIdServer.ts export default async function getItemByIdServer(props: TPropsServer): Promise<TResult> { const checkProps = zQueryProps.safeParse(props); if (!checkProps.success) { return({ code: "WrongData", message: checkProps.error }, HttpStatusCode.BadRequest); } const { data } = checkProps try { const result = await getCompanyById(data); if (isSuccess(result)) { return (result.payload, HttpStatusCode.Ok); } return ({ code: result.payload.code }, HttpStatusCode.BadRequest); } catch (e) { return({ code: ErrCode.UnknownError, message: "Неизвестная ошибка" }, HttpStatusCode.InternalServerError); } }

Здесь содержится вся бизнес-логика приложения. Функция на стороне frontend выполняет простую роль передачи запроса на сервер.

// getItemByIdClient.ts export default async function queryCompanyClient(data: QueryProps): Promise<QuerySuccess> { const res = await axios.get<QueryResult>(`${ROOT_PATH}/${data.id}`, { validateStatus: () => true }); if (res.status === HttpStatusCode.Ok && isSuccess(res.data)) { const data = zDbCompanyQuery.parse(res.data.payload); return data; } throw res.data.payload; }

Здесь вместо обычного возврата кода ошибки происходит throw error, так как функция будет использоваться в хуке useQuery от ReactQuery.

Как серверная часть, так и клиентская, осуществляют валидацию полученных данных через zod.

/hooks - добавляем ReactQuery

Хуки располагаются в отдельных папках

│ │ ├── hooks │ │ │ ├── use-get-item-by-id │ │ │ │ ├── queryKey.ts │ │ │ │ ├── useGetItemById.ts │ │ │ │ └── index.ts

В файле queryKey просто прописывается и экспортируется ключ, который будет использоваться для данного хука. Ее можно будет исопльзовать в других частях приложения, например, если нуждо будет сделать invalidate для запроса.

// .../queryKey.ts export const QUERY_KEY = "item-get-by-id";

Сам хук простой - он лишь вызывает уже созданную ранее функцию для frontend части:

import { useQuery } from "@tanstack/react-query"; import { GetItemByIdPropsClient, getItemByIdClient } from "shared/database"; import { QUERY_KEY } from "./queryKey"; export type UseGetItemProps = GetItemByIdPropsClient; export const useGetItemById = ({ id }: UseGetItemProps) => { const queryKey = [QUERY_KEY, id]; return useQuery(queryKey, () => getItemByIdClient({ id }), { suspense: true, }); };

и все это экспортируется в файле index.ts

// index.ts export { QUERY_KEY as QUERY_KEY_GET_ITEM } from "./queryKey"; export { useGetItemById } from "./useGetItemById";

Теперь в компоненте достаточно вызывать хук, чтобы получить необходимые данные:

const { data: item } = useGetItemById({ id })

Заключение

В приведенном примере может показаться излишним разделение простого запроса на столько частей. Но он позволяет организовывать код через изолированные и переиспользуемые части, каждая из которых сосредоточена на выполненни узкого круга задач соответствующего уровня абстракции.

Соответственно, разобраться в коде, а также провести его рефакторинг становится проще, так как сразу понятно, какого типа задачи где решаются.

11
Начать дискуссию