السلسلة: هذا الدرس جزء من سلسلة TanStack. اقرأ المقال الرئيسي.
هجرة تطبيق Next.js App Router إنتاجي نحو TanStack Start ليس قراراً يُتَّخذ باستخفاف. يجب وزن الفوائد (قراءة، type-safety كاملة، نقل multi-runtime) ضد التكاليف (إعادة كتابة أنماط server-first، خسارة مؤقتة لنظام بيئي، منحنى تعلّم). يقترح هذا الدرس خطة هجرة تدريجية، جدول مكافئات API مفصَّل، استراتيجيات لنقل Server Components، وإدارة إعادات SEO.
المتطلبات
- تطبيق Next.js 14 أو 15 إنتاجي مع App Router
- مطوّر متقدم بدوام كامل على الأقل
- معرفة عملية بـ Server Components وServer Actions وcache fetch
- بيئة staging معزولة
- وصول لسجلات الإنتاج
الخطوة 1 — تقييم ملاءمة الهجرة
ثلاثة أسئلة ملموسة:
- هل الفريق يصارع بانتظام cache fetch أو توجيه
'use server'؟ TanStack Start يحلّ هذا الألم بصراحة تجعل حالة التطبيق مقروءة. - هل المشروع يحتاج مغادرة Vercel نحو Cloudflare، Bun، AWS Lambda أو VPS؟ runtime Nitro لـ Start ينشر في كل مكان.
- هل المشروع يحتاج type-safety صارمة على routes وsearch params؟ TanStack Router يفعل هذا أصلياً.
إن كانت الإجابة نعم على اثنين من ثلاثة، الهجرة تستحق. وإلا، ابقَ على Next.js.
الخطوة 2 — رسم خريطة التطبيق الموجود
سبع فئات للجرد:
- كل routes
app/**/page.tsx - كل
layout.tsxومداها - كل Server Actions (ملفات أو inline مع
'use server') - كل routes API
app/api/**/route.ts - كل middlewares (
middleware.tsفي الجذر) - كل المكوّنات التي تستخدم hooks خاصة Next (
useRouter،usePathname،useSearchParams) - كل الإعدادات الخاصة (
next.config.js، redirects، rewrites، image optimization)
الخطوة 3 — اختيار استراتيجية تبديل
| الاستراتيجية | متى تستخدمها | المدة النمطية |
|---|---|---|
| Strangler مع reverse proxy | تطبيق حرج، SEO حساس، فريق متوسط | 4-8 أشهر |
| تعايش على نطاق فرعي | ميزة جديدة معزولة، تطبيق legacy مجمَّد | 2-4 أشهر |
| إعادة كتابة حسب module | تطبيق صغير، فريق greenfield | 2-5 أشهر |
استراتيجية strangler الأكثر أماناً. reverse proxy (Nginx، Cloudflare Workers، Caddy) يوجّه بعض URLs نحو التطبيق الجديد والباقي نحو القديم. التبديل يصير تغيير config في proxy.
الخطوة 4 — Bootstrap مشروع TanStack Start
npm create tanstack@latest my-app-v2
cd my-app-v2
npm install
npm run dev
ثبّت التبعيات التي يستخدمها التطبيق legacy أيضاً: Drizzle أو Prisma، Zod، client القاعدة، مكتبات UI.
الخطوة 5 — تحويل routing app/ إلى src/routes/
| Next.js App Router | TanStack Router |
|---|---|
app/page.tsx |
src/routes/index.tsx |
app/about/page.tsx |
src/routes/about.tsx |
app/blog/[slug]/page.tsx |
src/routes/blog/$slug.tsx |
app/[...slug]/page.tsx |
src/routes/$.tsx |
app/layout.tsx (root) |
src/routes/__root.tsx |
app/(marketing)/page.tsx |
src/routes/(marketing)/index.tsx |
app/dashboard/layout.tsx |
src/routes/dashboard/route.tsx |
app/_protected/layout.tsx (pathless) |
src/routes/_protected.tsx |
فخّ كلاسيكي: Next يستخدم [slug] بأقواس مربعة، TanStack Router يستخدم $slug بـ dollar. بحث-استبدال سريع يؤتمت 90% من العمل.
الخطوة 6 — نقل Server Components
TanStack Start لا يستخدم React Server Components. الأنماط الشائعة لها مكافئات مباشرة.
// قبل (Next.js Server Component)
async function PostList() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
// بعد (TanStack Start)
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
const postsQuery = queryOptions({
queryKey: ['posts'],
queryFn: () => fetch('https://api.example.com/posts').then(r => r.json()),
})
export const Route = createFileRoute('/posts/')({
loader: ({ context }) => context.queryClient.prefetchQuery(postsQuery),
component: PostList,
})
function PostList() {
const { data } = useSuspenseQuery(postsQuery)
return <ul>{data.map((p: any) => <li key={p.id}>{p.title}</li>)}</ul>
}
الخطوة 7 — تحويل Server Actions إلى server functions
// قبل (Next.js Server Action)
'use server'
import { z } from 'zod'
export async function createPost(data: { title: string }) {
const parsed = z.object({ title: z.string().min(3) }).parse(data)
return await db.insert(posts).values(parsed).returning()
}
// بعد (TanStack Start server function)
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
export const createPost = createServerFn({ method: 'POST' })
.inputValidator(z.object({ title: z.string().min(3) }))
.handler(async ({ data }) => {
return await db.insert(posts).values(data).returning()
})
الاستدعاء جانب المكوّن يصير await createPost({ data: { title: 'test' } }). للإلغاء، نستبدل revalidatePath بـ queryClient.invalidateQueries.
الخطوة 8 — هجرة routes API
// قبل (Next.js app/api/posts/route.ts)
export async function GET(request: Request) {
const posts = await db.select().from(postsTable)
return Response.json(posts)
}
// بعد (TanStack Start src/routes/api/posts.ts)
import { createFileRoute } from '@tanstack/react-router'
import { db } from '~/db/client'
import { posts as postsTable } from '~/db/schema'
export const Route = createFileRoute('/api/posts')({
server: {
handlers: {
GET: async () => {
const posts = await db.select().from(postsTable)
return Response.json(posts)
},
},
},
})
الخطوة 9 — استبدال hooks الخاصة بـ Next
| Next.js | TanStack Router |
|---|---|
useRouter() |
useRouter() (API مختلفة) |
router.push('/x') |
navigate({ to: '/x' }) |
usePathname() |
useLocation().pathname |
useSearchParams() |
useSearch() (typé) |
useParams() |
useParams({ from: '/route/$id' }) |
redirect('/login') |
throw redirect({ to: '/login' }) |
notFound() |
throw notFound() |
الخطوة 10 — إدارة إعادات SEO بلا خسارة
// مثال Worker frontal لإعادات
const REDIRECTS: Record<string, string> = {
'/products': '/produits',
'/blog/old-slug': '/blog/new-slug',
}
export default {
async fetch(request: Request) {
const url = new URL(request.url)
if (REDIRECTS[url.pathname]) {
return Response.redirect(url.origin + REDIRECTS[url.pathname], 301)
}
return fetch(request)
},
}
راقب Google Search Console أسبوعين إلى أربعة بعد كل موجة تبديل. أرسل URLs الجديدة عبر IndexNow.
فروق للحفظ
| الجانب | Next.js App Router | TanStack Start |
|---|---|---|
| Metadata صفحة | Export metadata |
head() في route |
| Loading UI | loading.tsx |
pendingComponent |
| Error UI | error.tsx |
errorComponent |
| Image optimization | next/image |
حل خارجي (unpic، Cloudflare Images) |
| Font optimization | next/font |
fontsource أو self-hosted |
| Middleware Edge | middleware.ts |
Worker Cloudflare في الأمام |
| Streaming RSC | streaming أصلي | defer في loader |
| ISR | revalidate على fetch |
staleTime Query + cache CDN |
أخطاء شائعة
| الخطأ | السبب | الحل |
|---|---|---|
| Hydration mismatch على تواريخ | مكوّنات تستخدم new Date() على حدّ SSR/client |
انقل في useEffect أو suppressHydrationWarning |
| Cache Query يُعاد تعيينه عند كل تنقل | QueryClient مُنشأ خارج router | دائماً عبر createRouter |
| Server Action مهاجَر لكن غير مستخدَم | المكوّن المُهاجَر لا يزال يستخدم useFormState |
استبدل بـ useMutation من Query |
| 404 على /api/auth/* بعد هجرة auth | route catch-all مفقود | أنشئ src/routes/api/auth/$.ts |
| Metadata مفقودة بعد النقل | Export metadata غير مستبدَل بـ head() |
استخدم head() في createFileRoute |
| Bundle أكبر من المتوقَّع | imports ديناميكية مفقودة عند التحويل | فعّل autoCodeSplitting: true |
أسئلة شائعة
كم تستغرق هجرة متوسطة؟ لتطبيق 30 route مع 10 server actions وmodule auth، احسب 6-12 أسبوع بدوام كامل لمطوّر متقدم. فريق اثنين يقسم تقريباً النصف.
هل يمكن الهجرة تدريجياً مع تطبيقين متوازيين؟ نعم، حتى موصى به. نمط strangler خلف reverse proxy يسمح باختبار كل route على نسب صغيرة من حركة المرور.
كيف ننقل next/image؟ لصور من نطاق مخصّص، استخدم مكوّن Image من مكتبة unpic الذي يدعم Cloudinary، ImageKit، Cloudflare Images.
وهل سيبقى SEO محفوظاً؟ نعم، شرط وضع إعادات 301 لأي URL يتغيّر ومراقبة Search Console.
هل من الأفضل انتظار 1.0 المستقرّ؟ ليس بالضرورة. RC مُعلن كـ build الذي سيخرج في 1.0. الهجرة الآن تعطي تقدماً 6-12 شهراً.