تطوير الويب

Server functions TanStack Start: RPC type-safe full-stack 2026

7 دقائق للقراءة

السلسلة: هذا الدرس جزء من سلسلة TanStack. اقرأ المقال الرئيسي.

Server functions في TanStack Start تغيّر طريقة كتابة كود full-stack في React 2026. بدل إنشاء route API، تسلسل الطلب يدوياً، إعادة تنميط الردّ جانب العميل، تعلن دالة خادم بـ createServerFn، تعرضها لـ React، وتحصل على استدعاء RPC type-safe من البداية إلى النهاية. الأنواع TypeScript تنتشر من handler إلى المكوّن، بلا توليد كود، بلا OpenAPI schema، بلا client مولَّد.

المتطلبات

  • Node.js 22 LTS مثبَّت
  • مشروع TanStack Start مُولَّد عبر npm create tanstack@latest مع TypeScript
  • TanStack Router وQuery موصولان
  • قاعدة PostgreSQL متاحة وDrizzle ORM مُهيّأ (drizzle-kit 0.45+ أو v1 beta)
  • Zod مثبَّت (npm install zod)
  • محرّر مع خادم TypeScript نشط

أنشئ مجلد app/server/ لجمع server functions. الاصطلاح ليس إلزامياً، لكنه يُساعد فصل الذهن.

الخطوة 1 — server function أساسية

// app/server/posts.ts
import { createServerFn } from '@tanstack/react-start'

export const getPosts = createServerFn({ method: 'GET' })
  .handler(async () => {
    return [
      { id: 1, title: 'Premier post', body: 'Hello server function' },
      { id: 2, title: 'Deuxieme post', body: 'TanStack Start v1' },
    ]
  })

عند التنفيذ، TanStack Start يحوّل هذا الاستدعاء إلى endpoint HTTP داخلي. حين تستدعيه جانب العميل، الـ bundler يحذف جسم handler من حزمة المتصفّح ويستبدله بـ fetch typé. autocomplete على getPosts() يُظهر النوع Promise<{ id: number; title: string; body: string }[]> بلا توضيح يدوي.

الخطوة 2 — إضافة validateur Zod

server function بلا validateur باب مفتوح. الـ builder يكشف .inputValidator() الذي يقبل schema متوافق (Zod، Valibot، ArkType). الـ validateur يُنفَّذ جانب الخادم قبل handler؛ إن أخفق، client يستلم خطأ مُسلسَلاً وhandler لا يُستدعى.

// app/server/posts.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'

const getPostByIdSchema = z.object({
  id: z.number().int().positive(),
})

export const getPostById = createServerFn({ method: 'GET' })
  .inputValidator(getPostByIdSchema)
  .handler(async ({ data }) => {
    return { id: data.id, title: 'Post ' + data.id, body: 'lorem' }
  })

الخطوة 3 — قراءة في قاعدة مع Drizzle

// app/server/posts.ts
import { createServerFn } from '@tanstack/react-start'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '~/db/client'
import { posts } from '~/db/schema'

export const getPostById = createServerFn({ method: 'GET' })
  .inputValidator(z.object({ id: z.number().int().positive() }))
  .handler(async ({ data }) => {
    const rows = await db.select().from(posts).where(eq(posts.id, data.id))
    if (rows.length === 0) {
      throw new Error('Post ' + data.id + ' introuvable')
    }
    return rows[0]
  })

نوع الإرجاع يُستدلّ مباشرة من Drizzle، لذلك جانب المكوّن تحصل على الأعمدة الدقيقة لجدول posts بلا تكرار تعريف. إن أعدت تسمية عمود، TypeScript يُبلّغ عن عدم التناسق في كل المكوّنات.

الخطوة 4 — كتابة mutation وإلغاء صلاحية TanStack Query

// app/server/posts.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { db } from '~/db/client'
import { posts } from '~/db/schema'

export const createPost = createServerFn({ method: 'POST' })
  .inputValidator(z.object({
    title: z.string().min(3).max(120),
    body: z.string().min(1),
  }))
  .handler(async ({ data }) => {
    const [row] = await db.insert(posts).values(data).returning()
    return row
  })

الـ server function تتجاهل cache. المكوّن React يُنسّق الإلغاء عبر queryClient.invalidateQueries في onSuccess من useMutation.

الخطوة 5 — استهلاك server function في مكوّن React

// app/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { createPost, getPosts } from '~/server/posts'

export const Route = createFileRoute('/posts')({ component: PostsPage })

function PostsPage() {
  const qc = useQueryClient()
  const [title, setTitle] = useState('')

  const { data, isLoading } = useQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts(),
  })

  const mutation = useMutation({
    mutationFn: (vars: { title: string; body: string }) =>
      createPost({ data: vars }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['posts'] }),
  })

  if (isLoading) return <p>Chargement...</p>

  return (
    <div>
      <ul>{data?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button onClick={() => mutation.mutate({ title, body: 'corps' })}>
        Creer
      </button>
    </div>
  )
}

الخطوة 6 — إدارة الأخطاء بنظافة

// app/routes/posts.$id.tsx
import { createFileRoute, ErrorComponent } from '@tanstack/react-router'
import { useSuspenseQuery } from '@tanstack/react-query'
import { getPostById } from '~/server/posts'

export const Route = createFileRoute('/posts/$id')({
  component: PostPage,
  errorComponent: ({ error }) => <ErrorComponent error={error} />,
  loader: ({ params }) => getPostById({ data: { id: Number(params.id) } }),
})

function PostPage() {
  const { id } = Route.useParams()
  const { data } = useSuspenseQuery({
    queryKey: ['post', id],
    queryFn: () => getPostById({ data: { id: Number(id) } }),
  })
  return <article><h1>{data.title}</h1><p>{data.body}</p></article>
}

إن رفع getPostById خطأً، errorComponent يستلم الكائن المُسلسَل والمستخدم يرى رسالة واضحة بدل شاشة بيضاء.

الخطوة 7 — توصيل middleware مصادقة

// app/server/middleware/auth.ts
import { createMiddleware } from '@tanstack/react-start'
import { verifySession } from '~/auth/session'

export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
  const cookie = request.headers.get('cookie') ?? ''
  const user = await verifySession(cookie)
  if (!user) throw new Error('Non authentifie')
  return next({ context: { user } })
})
// app/server/posts.ts (مقتطف محمي)
import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from './middleware/auth'

export const deletePost = createServerFn({ method: 'POST' })
  .middleware([authMiddleware])
  .inputValidator(z.object({ id: z.number().int().positive() }))
  .handler(async ({ data, context }) => {
    await db.delete(posts).where(eq(posts.id, data.id))
    return { ok: true, by: context.user.email }
  })

أي طلب بلا cookie صالح ينتهي بخطأ «Non authentifie» قبل استدعاء handler. منطق الأعمال يبقى مقروءاً: لا if (!user) return 401 مكرّر.

الخطوة 8 — رفع ملف بـ multipart

// app/server/upload.ts
import { createServerFn } from '@tanstack/react-start'
import { writeFile } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import path from 'node:path'

export const uploadAvatar = createServerFn({ method: 'POST' })
  .inputValidator((input: unknown) => {
    if (!(input instanceof FormData)) throw new Error('FormData attendu')
    const file = input.get('file')
    if (!(file instanceof File)) throw new Error('Champ file manquant')
    if (file.size > 5 * 1024 * 1024) throw new Error('Fichier trop gros')
    return { file }
  })
  .handler(async ({ data }) => {
    const buffer = Buffer.from(await data.file.arrayBuffer())
    const filename = randomUUID() + '-' + data.file.name
    await writeFile(path.join(process.cwd(), 'uploads', filename), buffer)
    return { url: '/uploads/' + filename }
  })

جانب العميل، نستدعي uploadAvatar({ data: formData }) مع FormData مبني من <input type="file">. TanStack Start يكتشف آلياً أن payload هو FormData ويرسله بـ multipart/form-data.

Server functions مقابل Server Actions Next.js

البعد TanStack Start Next.js Server Actions
الإعلان createServerFn({ method }) صريح توجيه 'use server' في رأس ملف
التحقق .inputValidator() إلزامي على عاتق المطور
Type-safety استدلال مباشر عبر builder استدلال عبر معامل، أهشّ على FormData
طرق HTTP GET أو POST POST فقط
Middleware createMiddleware سلسلة وtypé لا middleware أصلي
Cache مفوَّض لـ TanStack Query revalidatePath / revalidateTag مدمج
التسلسل JSON، FormData، ReadableStream JSON موسَّع (Map، Set، Date)
الاقتران بـ framework مستقلّ عن router مرتبط بـ App Router

أخطاء شائعة

الخطأ السبب الحل
نسيان غلاف data myFn({ id: 1 }) بدل myFn({ data: { id: 1 } }) دائماً غلّف payload في { data: ... }
كود خادم في حزمة client استيراد من ملف خارج route ضع server function في app/server/
Validateur typé any (input: any) بدل (input: unknown) استخدم unknown ودع Zod يستدلّ
أخطاء غير قابلة للتسلسل كائن خطأ بدوال أو دورات أنشئ subclass Error ببساطة
UI قديم بعد mutation لا invalidateQueries وصّل في onSuccess من useMutation
تسرّب متغيّرات بيئة process.env مقروء في ملف مشترك اعزل الإعداد في app/server/env.ts

أسئلة شائعة

هل server function لكل استدعاء API؟ لا. لـ APIs عامة لطرف ثالث (Stripe webhooks، OpenAI)، route مخصّصة أنسب. Server functions تتألق حين يكون client React هو أيضاً client الـ API.

هل يمكن استدعاء server function من خادم آخر؟ تقنياً نعم، endpoint المولَّد POST JSON، لكن ليس الاستخدام المتوقَّع. للـ inter-service، اعرض route app/api/ كلاسيكي.

كيف نختبر server function؟ handler يبقى دالة async قابلة للتصدير. استوردها في اختبار Vitest واستدعها مع data مزيَّفة. للـ middleware، mock getWebRequest عبر vi.mock.

هل server functions تدعم streaming؟ نعم. أرجع ReadableStream من handler، وجانب العميل اقرأه كـ stream fetch. مفيد لعرض ردود LLM رمزاً برمز.

الفرق بين server function وloader route؟ Loader يُستدعى آلياً من Router عند التنقل ونتيجته في cache حسب route. Server function تُستدعى صراحة، في أي وقت، بلا دورة حياة مرتبطة بـ route.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité