Як обʼєднати 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
змінити імплементацію метода і вуаля...
Питання/пропозиції/обурення в коментарі, якщо раптом.