frontend

TanStack Query + Next.js 16 App Router

١٧ يونيو ٢٠٢٦

TanStack Query + Next.js 16 App Router Prefetch (2026)

لعمل prefetch لبيانات TanStack Query في App Router الخاص بـ Next.js، قم باستدعاء prefetchQuery على QueryClient مخصص لكل طلب داخل Server Component، ثم قم بتغليف الشجرة الفرعية للعميل في <HydrationBoundary state={dehydrate(queryClient)}>. سيقوم استدعاء useQuery مطابق على العميل بقراءة البيانات عند أول ظهور (first paint).

ملخص

ستقوم بجلب قائمة على الخادم في App Router الخاص بـ Next.js 16 وتسليمها للعميل دون وميض تحميل (loading flash). يستخدم هذا البناء إصدارات next 16.2.9، و React 19.2.7، و @tanstack/React-query 5.101.0.1 ستقوم بإنشاء مصنع (factory) واحد لـ QueryClient يكون عبارة عن عميل جديد لكل طلب على الخادم ونسخة وحيدة (singleton) في المتصفح، وتقوم بعمل prefetch في Server Component، وتسلسل ذاكرة التخزين المؤقت (cache) باستخدام dehydrate، وإعادة ترطيبها (rehydrate) من خلال <HydrationBoundary>. بعد ذلك، ستقوم بتعيين staleTime غير صفري حتى لا يقوم العميل بإعادة الجلب فوراً، وتنهي العمل بمتغير تدفقي (streaming variant) يستخدم useSuspenseQuery. خصص حوالي 25 دقيقة.

ما ستتعلمه

  • كيفية إنشاء مصنع QueryClient يكون لكل طلب على الخادم ونسخة وحيدة في المتصفح
  • كيفية ربط QueryClientProvider في App Router باستخدام مكون Providers يحمل علامة 'use client'
  • كيفية عمل prefetch للبيانات في Server Component باستخدام prefetchQuery
  • كيف يقوم كل من dehydrate و HydrationBoundary بنقل ذاكرة التخزين المؤقت من الخادم إلى العميل
  • لماذا يستمر الاستعلام الذي تم عمل prefetch له في إعادة الجلب عند التحميل (mount)، وكيف يعالج staleTime ذلك
  • كيفية دفق استعلام معلق باستخدام useSuspenseQuery و shouldDehydrateQuery
  • كيفية عمل prefetch في مكان أقرب لمكان استخدام البيانات، بالإضافة إلى بديل العميل الواحد
  • كيفية التحقق من نجاح عملية الترطيب (hydration)، وكيفية إصلاح أوضاع الفشل الشائعة

المتطلبات الأساسية

  • Node.js 20.9+ (يتطلبه Next.js 16؛ Node 24 LTS هو الإصدار الحالي المدعوم حتى أبريل 2028)2
  • تطبيق Next.js 16 App Router جديد: npx create-next-app@16.2.9 my-app --ts --app
  • حزم محددة الإصدار، وهي الحالية على npm اعتباراً من 17 يونيو 2026:1
npm install --save-exact @tanstack/React-query@5.101.0 @tanstack/React-query-devtools@5.101.0
npm install --save-exact -D TypeScript@6.0.3 @types/React@19.2.17 @types/React-dom@19.2.3 @types/node@24.13.2

سوف نقوم بالجلب من https://jsonplaceholder.typicode.com، وهو API تجريبي عام مجاني لا يتطلب مفتاحاً، لذا فإن كل كتلة برمجية أدناه ستعمل كما هي مكتوبة.

أولاً، طبقة بيانات صغيرة. أنشئ lib/posts.ts:

export type Post = {
  id: number;
  title: string;
  body: string;
};

const API = "https://jsonplaceholder.typicode.com";

export async function getPosts(): Promise<Post[]> {
  const res = await fetch(`${API/posts?_limit=10`);
  if (!res.ok) throw new Error(`Failed to load posts: ${res.status`);
  return res.json() as Promise<Post[]>;
}

الخطوة 1: إنشاء مصنع QueryClient يكون لكل طلب على الخادم

من الأخطاء الشائعة في App Router مشاركة QueryClient واحد عبر كل الطلبات على الخادم. الخادم طويل الأمد، لذا فإن العميل المشترك يسرب بيانات مستخدم واحد إلى ذاكرة التخزين المؤقت للمستخدم التالي. الحل هو مصنع يقوم دائماً ببناء عميل جديد على الخادم، ويعيد استخدام عميل واحد في المتصفح. أنشئ app/get-query-client.ts:

import {
  environmentManager,
  QueryClient,
  defaultShouldDehydrateQuery,
} from "@tanstack/React-query";

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // Above 0 so the client treats prefetched data as fresh
        // and does NOT refetch on mount.
        staleTime: 60 * 1000,
      },
      dehydrate: {
        // Also ship queries that are still pending, so streaming works.
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === "pending",
      },
    },
  });
}

let browserQueryClient: QueryClient | undefined = undefined;

export function getQueryClient() {
  if (environmentManager.isServer()) {
    // Server: a brand-new client for every request.
    return makeQueryClient();
  }
  // Browser: reuse one client for the whole tab.
  if (!browserQueryClient) browserQueryClient = makeQueryClient();
  return browserQueryClient;
}

هناك تفصيلان مهمان هنا. environmentManager.isServer() هي الطريقة التي تكتشف بها وثائق TanStack Query الحالية الخادم؛ في الإصدار 5.101.0 هي عبارة عن استدعاء دالة. الدروس القديمة تستورد قيمة منطقية بدلاً من ذلك (import { isServer } from "@tanstack/React-query" ثم if (isServer)) — هذا التصدير لا يزال يعمل ولكنه الآن مهجور لصالح environmentManager، لذا لا تدع قصاصة برمجية منسوخة تربكك.3 إن staleTime البالغ 60 ثانية وتجاوز shouldDehydrateQuery كلاهما سيظهر أهميتهما في خطوات لاحقة.

الخطوة 2: ربط QueryClientProvider

يستخدم QueryClientProvider سياق (context) React، لذا يجب أن يتواجد في Client Component. أنشئ app/providers.tsx:

"use client";

import { QueryClientProvider } from "@tanstack/React-query";
import { ReactQueryDevtools } from "@tanstack/React-query-devtools";
import { getQueryClient } from "./get-query-client";

export default function Providers({ children }: { children: React.ReactNode }) {
  // No useState: getQueryClient() already returns a stable per-tab client.
  const queryClient = getQueryClient();

  return (
    <QueryClientProvider client={queryClient>
      {children
      <ReactQueryDevtools initialIsOpen={false/>
    </QueryClientProvider>
  );
}

لاحظ التعليق: لا تقم بتغليف getQueryClient() في useState. تضمن النسخة الوحيدة في المتصفح بالفعل عميلاً مستقراً واحداً لكل علامة تبويب، ولا يضيف useState شيئاً هنا. ثم قم بتثبيت المزود (provider) مرة واحدة في app/layout.tsx:

import Providers from "./providers";

export default function RootLayout({
  children
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children</Providers>
      </body>
    </html>
  );
}

الخطوة 3: عمل prefetch لـ TanStack Query على الخادم في Server Component

الآن نأتي إلى جوهر الموضوع. يقوم Server Component بإنشاء عميل، وينتظر الـ prefetch، ويمرر ذاكرة التخزين المؤقت المتسلسلة إلى الأسفل من خلال <HydrationBoundary>.4 أنشئ app/posts/page.tsx:

import { dehydrate, HydrationBoundary } from "@tanstack/React-query";
import { getQueryClient } from "../get-query-client";
import { getPosts } from "../../lib/posts";
import PostList from "./posts";

export default async function PostsPage() {
  const queryClient = getQueryClient();

  // Fetch on the server and warm the cache for this exact query key.
  await queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient>
      <PostList />
    </HydrationBoundary>
  );
}

يقوم prefetchQuery بتشغيل getPosts على الخادم ويخزن النتيجة تحت المفتاح ["posts"]. يقوم dehydrate(queryClient) بتسلسل ذاكرة التخزين المؤقت تلك إلى كائن بسيط، ويقوم HydrationBoundary — وهو نفسه Client Component — بإعادة بناء تلك المدخلات في عميل المتصفح قبل أن يتم تصيير (render) مكوناتك. العقد الأساسي هو مفتاح الاستعلام: يجب على العميل طلب نفس المفتاح بالضبط الذي تم عمل prefetch له.

الخطوة 4: HydrationBoundary و dehydrate على العميل

يقرأ مكون العميل ذاكرة التخزين المؤقت باستخدام useQuery عادي. نظراً لأنه تم ترطيب المدخل، فإن البيانات تكون موجودة عند أول تصيير — لا توجد حالة تحميل تظهر فجأة. أنشئ app/posts/posts.tsx:

"use client";

import { useQuery } from "@tanstack/React-query";
import { getPosts } from "../../lib/posts";

export default function PostList() {
  // Same queryKey as the server prefetch -> data is here on first paint.
  const { data, isPending isError error } = useQuery({
    queryKey: ["posts"],
    queryFn: getPosts
  });

  if (isPending) return <p>Loading posts...</p>;
  if (isError) return <p>Something went wrong: {error.message</p>;

  return (
    <ul>
      {data.map((post) => (
        <li key={post.id>{post.title</li>
      ))
    </ul>
  );
}

لا تزال queryFn موجودة هنا، وهذا مقصود: فهي المرجع لعمليات التنقل من جانب العميل إلى هذا المسار حيث لم يتم تشغيل prefetch على الخادم، وهي مصدر إعادة الجلب بمجرد أن تصبح البيانات قديمة (stale). في أول تحميل تم عمل prefetch له، ببساطة لا يتم استدعاؤها.

الخطوة 5: staleTime الذي يوقف الجلب المزدوج

هنا يكمن الفخ الخفي. القيمة الافتراضية لـ staleTime في TanStack Query هي 0، مما يعني أن الاستعلام يعتبر قديماً في اللحظة التي يتم فيها تخزينه مؤقتاً. لذا حتى مع وجود prefetch مثالي، يتم تحميل العميل، ويرى مدخلاً "قديماً"، ويقوم فوراً بإعادة الجلب في الخلفية — وبذلك يضيع عمل الخادم وتظهر طلبات مكررة في علامة تبويب الشبكة (network tab). تعيين staleTime فوق الصفر في الخطوة 1 هو الحل: خلال أول 60 ثانية، تعتبر البيانات المرطبة "جديدة" (fresh)، لذا يقوم العميل بتصييرها ويتخطى إعادة الجلب.

يمكنك تأكيد الآلية مباشرة. مباشرة بعد prefetch مع staleTime: 60 * 1000، سيظهر الاستعلام المخزن مؤقتاً أن isStale() === false، وهذا هو بالضبط سبب عدم تشغيل إعادة الجلب عند التحميل. ارفع أو اخفض المدة لتناسب سرعة تغير بياناتك؛ فقط اجعلها فوق الصفر كلما قمت بعمل prefetch.

الخطوة 6: دفق استعلام معلق باستخدام useSuspenseQuery

انتظار الـ prefetch يمنع الاستجابة حتى تصبح البيانات جاهزة. بالنسبة للبيانات الأبطأ، يمكنك بدلاً من ذلك الدفق (stream): ابدأ الجلب، وأرسل الاستعلام الذي لا يزال معلقاً، واتركه يكتمل عبر الشبكة داخل حدود Suspense.4 هذا هو السبب في أن makeQueryClient قام بتجاوز shouldDehydrateQuery ليشمل أيضاً الاستعلامات pending — حيث أن الوضع الافتراضي يتجاهلها. أنشئ app/posts-streaming/page.tsx:

import { dehydrate HydrationBoundary } from "@tanstack/React-query";
import { Suspense } from "React";
import { getQueryClient } from "../get-query-client";
import { getPosts } from "../../lib/posts";
import StreamingPostList from "./posts";

// No `async`, no `await`: kick off the fetch and stream the result in.
export default function StreamingPostsPage() {
  const queryClient = getQueryClient();

  // Note: no await. The pending query is dehydrated and streamed.
  void queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: getPosts
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient>
      <Suspense fallback={<p>Streaming posts...</p>>
        <StreamingPostList />
      </Suspense>
    </HydrationBoundary>
  );
}

يستخدم مكون العميل useSuspenseQuery، والذي لا يعيد أبداً بيانات undefined ويتوقف مؤقتاً (suspends) حتى يكتمل المدخل. أنشئ app/posts-streaming/posts.tsx:

"use client";

import { useSuspenseQuery } from "@tanstack/React-query";
import { getPosts } from "../../lib/posts";

export default function StreamingPostList() {
  // Suspends until ready; data is always defined here.
  const { data } = useSuspenseQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
  });

  return (
    <ul>
      {datamap((post) => (
        <li key={postid}>{posttitle}</li>
      ))}
    </ul>
  );
}

يعمل هذا لأن React يمكنه تحويل الـ promise المعلق (pending) إلى صيغة تسلسلية (serialize) للعميل، كما أن تجاوز shouldDehydrateQuery يضع الاستعلام المعلق في حالة الـ dehydrated منذ البداية.

الجلب المسبق (Prefetch) بالقرب من مكان استخدام البيانات

لست مقيداً بالجلب المسبق في page.tsx. وبما أن مكونات الخادم (Server Components) متداخلة، يمكنك إجراء الجلب المسبق في layout.tsx أو في أي مكون خادم متداخل، مع وضع <HydrationBoundary> حول الشجرة الفرعية التي تحتاج البيانات فقط. وجود حدود (boundaries) متعددة في صفحة واحدة أمر جيد — فكل منها يمكنه إنشاء عميله الخاص وتجفيف (dehydrate) الجزء الخاص به. السبب وراء الجلب المسبق في مستويات أعمق هو التوازي: يقوم Next.js بجلب أجزاء المسار (route segments) بالتوازي، لذا فإن توزيع الجلب المسبق عبر التخطيط (layout)، والصفحة، والمسارات المتوازية يسمح للإطار بتسطيح ما كان سيصبح شلال طلبات (request waterfall) من جانب الخادم.4

إذا كنت تفضل عميلاً واحداً للطلب بالكامل بدلاً من عميل لكل مكون خادم، فقم بلف مصنع واحد (single factory) في cache الخاص بـ React، والذي يكون نطاقه محدداً لكل طلب حتى لا يتسرب أي شيء بين المستخدمين:

// app/get-query-client.ts (بديل العميل الواحد لكل طلب)
import { QueryClient } from "@tanstack/React-query";
import { cache } from "React";

// cache() نطاقه محدد لكل طلب، لذا لا تتسرب البيانات أبداً بين الطلبات.
export const getQueryClient = cache(() => new QueryClient());

المقايضة هنا هي أن كل استدعاء لـ dehydrate(getQueryClient()) يقوم بتحويل العميل بأكمله إلى صيغة تسلسلية، بما في ذلك الاستعلامات من مكونات الخادم غير المرتبطة، لذا ستدفع تكلفة إضافية بسيطة في التحويل التسلسلي مقابل راحة الحصول على نفس العميل في أي مكان.4

التحقق

قم بتشغيل npm run dev وافتح /posts. هناك ثلاثة فحوصات تؤكد نجاح عملية الـ hydration:

  1. عرض المصدر (View source). عناوين <li> التي تم تصييرها موجودة في HTML الأولي، ولم يتم حقنها لاحقاً بواسطة JavaScript. إذا كانت القائمة مفقودة من HTML الخام، فهذا يعني أن الجلب المسبق لم يعمل.
  2. تبويب الشبكة (Network tab). عند إعادة التحميل القوي لصفحة /posts، لا يوجد طلب من جانب العميل إلى API الخاص بالمنشورات في أول 60 ثانية. وجود طلب مكرر يعني أن staleTime لا يزال 0.
  3. أدوات مطوري React Query. يظهر استعلام ["posts"] بالفعل في حالة success عند أول ظهور للرسم (first paint)، بدلاً من الانتقال من حالة pending.

يمكنك أيضاً التحقق من عقد الـ dehydration بشكل منفصل، بدون متصفح. احفظ هذا كـ verify.mjs وقم بتشغيل node verify.mjs:

import { QueryClient, dehydrate } from "@tanstack/React-query";

const sample = [{ id: 1, title: "first", body: "..." }];

// Awaited prefetch -> يتم التقاط الاستعلام مع بياناته.
const qc = new QueryClient();
await qcprefetchQuery({ queryKey: ["posts"], queryFn: async () => sample });
consolelog("queries dehydrated:", dehydrate(qc)querieslength); // 1

// يتم إسقاط الاستعلام المعلق ما لم تختر تضمينه.
const qc2 = new QueryClient();
void qc2prefetchQuery({
  queryKey: ["posts"],
  queryFn: () => new Promise((r) => setTimeout(() => r(sample), 50)),
});
consolelog("default pending dehydrated:", dehydrate(qc2)querieslength); // 0

السجل الأول يطبع 1 والثاني يطبع 0. هذا الـ 0 الثاني هو بالضبط السبب في أن البث (streaming) يحتاج إلى تجاوز shouldDehydrateQuery من الخطوة 1.

الأخطاء الشائعة

dehydrate() يعيد { queries: [] } فارغاً. إما أنك لم تقم بـ await للجلب المسبق (لذا كان الاستعلام لا يزال معلقاً عند التجفيف، والإعداد الافتراضي يسقط الاستعلامات المعلقة)، أو أنك قمت بتجفيف QueryClient مختلف عن الذي قمت بالجلب المسبق عليه. انتظر الجلب المسبق لنمط الحظر (blocking pattern)، أو أضف تجاوز pending وحدود Suspense لنمط البث.5

البيانات صحيحة، لكن العميل يعيد الجلب فوراً عند التحميل. هذا بسبب القيمة الافتراضية staleTime: 0. قم بتعيين staleTime غير صفري في makeQueryClient بحيث يتم التعامل مع البيانات التي تم عمل hydration لها على أنها طازجة.4

environmentManager.isServer is not a function. أنت تستخدم إصداراً من TanStack Query أقدم من الإصدار الذي قدم environmentManager (تمت إضافته في pull request #10199). إما أن تقوم بالترقية إلى 5.101.0، أو تستخدم القيمة المنطقية (boolean) التي لا تزال مُصدرة بدلاً من ذلك: import { isServer } from "@tanstack/React-query" و if (isServer).3

مفتاح الاستعلام (query key) غير متطابق. تعتمد الـ hydration على مفتاح الاستعلام المتسلسل. ["posts"] على الخادم و ["posts", undefined] على العميل هما مفتاحان مختلفان، لذا سيعتبرهما العميل مفقودين ويقوم بالجلب مرة أخرى. حافظ على تطابق المفاتيح تماماً بايت مقابل بايت.

حاولت الجلب باستخدام Server Action في queryFn. تنصح TanStack Query بعدم القيام بذلك: تعمل Server Actions بشكل تسلسلي عند استدعائها من العميل، مما يتعارض مع كيفية قيام Query بالجلب وإعادة الجلب، ويمكن أن يترك الاستعلام عالقاً في حالة الانتظار (pending). قم بالجلب من route handler أو طبقة RPC بدلاً من ذلك، واحتفظ بـ Server Actions للعمليات التي تغير البيانات (mutations).6

الخطوات التالية ومزيد من القراءة

لديك الآن بيانات TanStack Query تم جلبها مسبقاً من الخادم ومعمول لها hydration بدون وميض تحميل وبدون جلب مكرر. من هنا، اربط هذا مع بث Next.js 16 و Suspense مع use cache للتحكم في كيفية تصيير الهيكل (shell) المتدفق، واستخدم Server Actions و واجهة المستخدم المتفائلة (optimistic UI) في Next.js 16 لجانب التعديلات الذي يقترن به useMutation. إذا كنت تستخدم أيضاً TanStack Router، فإن نفس الانضباط القائم على المفاتيح يظهر في بارامترات البحث الآمنة من حيث النوع مع TanStack Router.

Footnotes

  1. تم تأكيد الإصدارات في سجل npm بتاريخ 17-06-2026: next 16.2.9، React/React-dom 19.2.7، @tanstack/React-query و @tanstack/React-query-devtools 5.101.0، TypeScript 6.0.3، @types/React 19.2.17، @types/React-dom 19.2.3، @types/node 24.13.2. https://www.npmjs.com/package/@tanstack/React-query 2

  2. جدول إصدارات Node.js (إصدار Node 24 "Krypton" Active LTS): https://GitHub.com/nodejs/release — ويصرح Next.js 16 بأن engines.node هو >=20.9.0.

  3. في @tanstack/React-query 5.101.0، يعد environmentManager.isServer دالة، بينما isServer الأقدم هو قيمة منطقية؛ كلاهما مُصدر، والقيمة المنطقية isServer أصبحت الآن مهجورة (deprecated) لصالح environmentManager.isServer() (TanStack/query PR #10199). تم التحقق من ذلك مقابل الحزمة المثبتة. 2

  4. "Advanced Server Rendering," وثائق TanStack Query v5 لـ React (أنماط الـ get-query-client، و staleTime، و shouldDehydrateQuery، والـ streaming): https://tanstack.com/query/v5/docs/framework/React/guides/advanced-ssr 2 3 4 5

  5. تقارير متكررة في App Router تفيد بأن الاستعلام الذي تم جلبه مسبقاً (prefetched query) لا يزال يعيد الجلب على العميل عند التحميل (mount)، أو أن الـ HydrationBoundary يفشل في توصيل البيانات المجلوبة مسبقاً إلى المستهلك: مناقشة TanStack/query رقم 7184 ("useQuery fetches in client upon mount") والمشكلة رقم 8479 ("HydrationBoundary not working if prefetched query accessed via layout"). https://GitHub.com/TanStack/query/discussions/7184

  6. دليل Advanced Server Rendering الخاص بـ TanStack Query يحذر من استخدام Next.js Server Actions للجلب داخل الـ queryFn (ويستشهد بالمشكلات TanStack/query رقم 7934 ورقم 6264): https://tanstack.com/query/v5/docs/framework/React/guides/advanced-ssr