Як обʼєднати 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
Файл із серверною функцією яка доступна виключно на сервері. Далі її можна використовувати у серверних модулях.
'use server';
export async function getEntity(id: number) {
const entities = {
1: {title: 'Entity #1'},
2: {title: 'Entity #2'},
};
// Це імітація взаємодії із БД чи стороннім API
return Promise.resolve(entities[id]);
}
@/api/entity/index.ts
Модуль із функцією, що інстанціює мінімально необхідний обʼєкт query. Далі його можна використовувати у клієнтських модулях.
import {queryOptions} from '@tanstack/react-query';
import {getEntity} from './server';
export const GET_ENTITY_KEY_BASE = ['entity'];
export function getEntityQuery(id: number) {
return queryOptions({
queryKey: [...GET_ENTITY_KEY_BASE, id],
queryFn: () => getEntity(id),
});
}
@/app/page.tsx
Модуль сторінки, що буде пререндеритись на сервері, а також гідрувати QueryClient даними зібраними на сервері.
import {HydrationBoundary, dehydrate} from '@tanstack/react-query';
import {getEntityQuery, GET_ENTITY_KEY_BASE} from '@/api/entity';
import {getEntity} from '@/api/entity/server';
type PageProps = {
params: Promise<{id: string}>;
}
export async function generateMetadata({params}: PageProps) {
const id = Number((await params).id);
const entity = await getEntity(id);
return {title: entity.title};
}
export default async function Page({params}: PageProps) {
const queryClient = getQueryClient();
const id = Number((await params).id);
await queryClient.prefetchQuery(getEntityQuery(id));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Container />
</HydrationBoundary>
);
}
Проблеми
Як видно, коли таких кверів стає багато, будемо мати велику кількисть фактично однакових функцій. Єдина різниця, це queryKey
і queryFn
.
То ж на днях вигадав абстракцію, щоб трошки спростити собі життя в цьому. Із профіту - менше однакового коду, повна підтримка типів, максимальна гнучкість, а також не потрібно вигадувати імена константам що зберігають базовий ключ квері. Це може знадобитись для оновлення чи інвалідації кеша кверів, після успішної мутації цих даних.
Тепер ось так
@/api/entity/server.ts
Модуль із серверною функцією лишається без змін.
'use server';
export async function getEntity(id: number) {
const entities = {
1: {title: 'Entity #1'},
2: {title: 'Entity #2'},
};
// Це імітація взаємодії із БД чи стороннім API
return Promise.resolve(entities[id]);
}
@/utils/query.ts
Власне сама імплементація класа утиліти
import {QueryKey} from '@tanstack/react-query';
export class Query<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
QueryFn extends (...params: any[] | never) => any,
Key extends QueryKey = QueryKey,
Params extends unknown[] | never = Parameters<QueryFn>,
Data = ReturnType<QueryFn>,
> {
public queryFn: QueryFn;
public baseKey: Key;
constructor(queryFn: QueryFn, baseKey: Readonly<Key>) {
this.baseKey = baseKey;
this.queryFn = queryFn;
}
public getKey = (...params: Params) => {
return [...this.baseKey, ...params] as const;
};
public getQuery = (...params: Params) => {
return {
queryKey: this.getKey(...params),
queryFn: () => this.queryFn(...params) as Data,
};
};
}
Із типами ще до кінця не розібрався, щоб було максимально гарно, але цим вже можна користуватись.
@/api/entity/index.ts
Обʼєкт квері інстанціюється за допомогою того класу утиліти.
import {Query} from '@/utils/query';
import {getEntity} from './server';
export const getEntityQuery = new Query(getEntity, ['entity'] as const);
Тепер із обʼєкта getEntityQuery
маємо доступ до:
- Серверної функції за допомогою
getEntityQuery.queryFn(id)
. - Обʼєкта квері через
getEntityQuery.getQuery(id)
. - Бази ключа, завдяки
getEntityQuery.baseKey
- А також, можемо згенерувати повний ключ.
getEntityQuery.getKey(id)
@/app/page.tsx
Модуль сторінки з якого видно різницю. Тепер достатно імпортувати обʼєкт з якого маємо доступ одразу і до серверної функції, і до саомї квері, а також базового ключа чи повного за необхідності.
import {HydrationBoundary, dehydrate} from '@tanstack/react-query';
import {getEntityQuery} from '@/api/entity';
type PageProps = {
params: Promise<{id: string}>;
}
export async function generateMetadata({params}: PageProps) {
const id = Number((await params).id);
const entity = await getEntityQuery.queryFn(id);
return {title: entity.title};
}
export default async function Page({params}: PageProps) {
const queryClient = getQueryClient();
const id = Number((await params).id);
await queryClient.prefetchQuery(getEntityQuery.getQuery(id));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Container />
</HydrationBoundary>
);
}
Такий підхід гарантує використання тої самої серверної функції як в клієнтських модулях так і в серверних. Типи повністю синхронізовані, тобто якщо серверна функція змінить свій API це вплине і на всі клієнтські квері похідні від неї. Ключ квері також синхронізований із кверею.
В разі якщо необхідно змінити логіку за якою формується повний ключ, можна віднаслідуватись від класу Query
змінити імплементацію метода і вуаля…
Питання/пропозиції/обурення в коментарі, якщо раптом.