Як обʼєднати nextjs server functions із tanstack query і мати з того зиск.

До головної сторінки

Останнім часом я часто працюю зі стеком Next.js + TanStack Query. Колись вигадав для себе підхід до структурування модулів, щоб мати змогу використовувати серверні функції як безпосередньо, так і в клієнтських компонентах.

Це дуже зручно, коли потрібна вся потужність TanStack Query з його кешуванням, гідрацією, мутаціями тощо, але водночас хочеться “приховати” бізнес-логіку, структуру бази даних або стороннє API від зайвих очей.

🔧 У чому концепція

  • Чіткий модульний поділ
  • Усі звернення до бази даних, зовнішніх API та потужна бізнес‑логіка містяться в окремому шарі — серверному модулі.
  • Це дозволяє контролювати, що та як передається далі у клієнт.
  • Універсальні серверні функції.
  • Пишете універсальні функції типу fetchUser(), updatePost(), які використовуються одразу в pages/api (або app-роутингу - next.js) і в клієнтських компонентах через TanStack Query.
  • Таким чином одна й та ж функція грає роль і API-ендпоінту, і запиту з клієнта з кешем, мутаціями, гідрацією.
  • Максимум переваг TanStack Query
  • Надійне кешування, оновлення після мутацій, гідрація тощо — без дублювання логіки чи повторних запитів.
  • Простота масштабування — адже клієнт запускає ті самі функції, що й сервер, без дублювання коду.
  • Ізоляція бізнес‑логіки
  • Витягуєте всю сутність запитів у захищений шар.
  • Клієнт не бачить внутрішнього влаштування — тільки обмежений інтерфейс.

Спочатку це виглядало це наступним чином

@/api/entity/server.ts

Файл із серверною функцією яка доступна виключно на сервері. Далі її можна використовувати у серверних модулях.

1'use server';
2
3export async function getEntity(id: number) {
4  const entities = {
5    1: {title: 'Entity #1'},
6    2: {title: 'Entity #2'},
7  };
8  // Це імітація взаємодії із БД чи стороннім API
9  return Promise.resolve(entities[id]);
10}
11

@/api/entity/index.ts

Модуль із функцією, що інстанціює мінімально необхідний обʼєкт query. Далі його можна використовувати у клієнтських модулях.

1import {queryOptions} from '@tanstack/react-query';
2import {getEntity} from './server';
3
4export const GET_ENTITY_KEY_BASE = ['entity'];
5
6export function getEntityQuery(id: number) {
7  return queryOptions({
8    queryKey: [...GET_ENTITY_KEY_BASE, id],
9    queryFn: () => getEntity(id),
10  });
11}
12

@/app/page.tsx

Модуль сторінки, що буде пререндеритись на сервері, а також гідрувати QueryClient даними зібраними на сервері.

1import {HydrationBoundary, dehydrate} from '@tanstack/react-query';
2import {getEntityQuery, GET_ENTITY_KEY_BASE} from '@/api/entity';
3import {getEntity} from '@/api/entity/server';
4
5type PageProps = {
6  params: Promise<{id: string}>;
7}
8
9export async function generateMetadata({params}: PageProps) {
10  const id = Number((await params).id);
11
12  const entity = await getEntity(id);
13
14  return {title: entity.title};
15}
16
17export default async function Page({params}: PageProps) {
18  const queryClient = getQueryClient();
19
20  const id = Number((await params).id);
21
22  await queryClient.prefetchQuery(getEntityQuery(id));
23
24  return (
25    <HydrationBoundary state={dehydrate(queryClient)}>
26      <Container  />
27    </HydrationBoundary>
28  );
29}
30

Проблеми

Як видно, коли таких кверів стає багато, будемо мати велику кількисть фактично однакових функцій. Єдина різниця, це queryKey і queryFn.

То ж на днях вигадав абстракцію, щоб трошки спростити собі життя в цьому. Із профіту - менше однакового коду, повна підтримка типів, максимальна гнучкість, а також не потрібно вигадувати імена константам що зберігають базовий ключ квері. Це може знадобитись для оновлення чи інвалідації кеша кверів, після успішної мутації цих даних.

Тепер ось так

@/api/entity/server.ts

Модуль із серверною функцією лишається без змін.

1'use server';
2
3export async function getEntity(id: number) {
4  const entities = {
5    1: {title: 'Entity #1'},
6    2: {title: 'Entity #2'},
7  };
8  // Це імітація взаємодії із БД чи стороннім API
9  return Promise.resolve(entities[id]);
10}
11

@/utils/query.ts

Власне сама імплементація класа утиліти

1import {QueryKey} from '@tanstack/react-query';
2
3export class Query<
4  // eslint-disable-next-line @typescript-eslint/no-explicit-any
5  QueryFn extends (...params: any[] | never) => any,
6  Key extends QueryKey = QueryKey,
7  Params extends unknown[] | never = Parameters<QueryFn>,
8  Data = ReturnType<QueryFn>,
9> {
10  public queryFn: QueryFn;
11  public baseKey: Key;
12
13  constructor(queryFn: QueryFn, baseKey: Readonly<Key>) {
14    this.baseKey = baseKey;
15    this.queryFn = queryFn;
16  }
17
18  public getKey = (...params: Params) => {
19    return [...this.baseKey, ...params] as const;
20  };
21
22  public getQuery = (...params: Params) => {
23    return {
24      queryKey: this.getKey(...params),
25      queryFn: () => this.queryFn(...params) as Data,
26    };
27  };
28}
29
30

Із типами ще до кінця не розібрався, щоб було максимально гарно, але цим вже можна користуватись.

@/api/entity/index.ts

Обʼєкт квері інстанціюється за допомогою того класу утиліти.

1import {Query} from '@/utils/query';
2import {getEntity} from './server';
3
4export const getEntityQuery = new Query(getEntity, ['entity'] as const);
5

Тепер із обʼєкта getEntityQuery маємо доступ до:

  • Серверної функції за допомогою getEntityQuery.queryFn(id).
  • Обʼєкта квері через getEntityQuery.getQuery(id).
  • Бази ключа, завдяки getEntityQuery.baseKey
  • А також, можемо згенерувати повний ключ. getEntityQuery.getKey(id)

@/app/page.tsx

Модуль сторінки з якого видно різницю. Тепер достатно імпортувати обʼєкт з якого маємо доступ одразу і до серверної функції, і до саомї квері, а також базового ключа чи повного за необхідності.

1import {HydrationBoundary, dehydrate} from '@tanstack/react-query';
2import {getEntityQuery} from '@/api/entity';
3
4type PageProps = {
5  params: Promise<{id: string}>;
6}
7
8export async function generateMetadata({params}: PageProps) {
9  const id = Number((await params).id);
10
11  const entity = await getEntityQuery.queryFn(id);
12
13  return {title: entity.title};
14}
15
16export default async function Page({params}: PageProps) {
17  const queryClient = getQueryClient();
18
19  const id = Number((await params).id);
20
21  await queryClient.prefetchQuery(getEntityQuery.getQuery(id));
22
23  return (
24    <HydrationBoundary state={dehydrate(queryClient)}>
25      <Container  />
26    </HydrationBoundary>
27  );
28}
29

Такий підхід гарантує використання тої самої серверної функції як в клієнтських модулях так і в серверних. Типи повністю синхронізовані, тобто якщо серверна функція змінить свій API це вплине і на всі клієнтські квері похідні від неї. Ключ квері також синхронізований із кверею.

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

Питання/пропозиції/обурення в коментарі, якщо раптом.