راستش را بخواهید، Deep Linking یکی از آن قابلیتهایی است که در نگاه اول خیلی ساده به نظر میرسد؛ یک فایل JSON اینجا، یک Bundle ID آنجا و تمام. ولی به محض اینکه یک SHA-256 اشتباه، یک فایل AASA نامعتبر یا کش CDN اپل وارد ماجرا میشود، تازه میفهمید چرا این موضوع تا این حد دردسرساز شده است. خودم بارها صبح را با ادعای «دیشب همهچیز کار میکرد» شروع کردهام و نتیجه؟ یک فاصله اضافه در فایل assetlinks.json بوده.
خب، بیایید وارد ماجرا شویم. در این راهنمای جامع ۲۰۲۶، یاد میگیریم چطور با Expo Router نسخه ۴ و SDK 55 یک سیستم Deep Linking کامل بسازیم؛ آن را تست کنیم و رایجترین مشکلاتی را که در production سرتان میآورند، یکبهیک حل کنیم.
تفاوت Deep Link، Universal Link و App Link چیست؟
پیش از کدنویسی، باید سه اصطلاحی را که اغلب بهجای هم استفاده میشوند روشن کنیم (وگرنه در بحثهای تیم همیشه سوءتفاهم پیش میآید):
- Custom Scheme Deep Link: لینکهایی مثل
myapp://product/123که فقط در صورت نصب اپ کار میکنند. اگر اپ نصب نباشد، لینک بهسادگی شکست میخورد. - Universal Link (iOS): از پروتکل
https://استفاده میکند و توسط فایلapple-app-site-associationروی دامنه شما تأیید میشود. اگر اپ نصب باشد، در اپ باز میشود؛ در غیر این صورت، Safari عهدهدارش میشود. - Android App Link: همان نسخه گوگلی Universal Link است که با
assetlinks.jsonو ویژگیautoVerify=trueدر intent filter کار میکند.
توصیه من برای سال ۲۰۲۶ این است: لینکهای مبتنیبر https (یعنی Universal/App Link) را بهعنوان روش اصلی نگه دارید و custom scheme را فقط برای موارد خاصی مثل OAuth callback کنار بگذارید. اپل و گوگل هر دو امنیت را در اولویت قرار دادهاند، و custom scheme بدون تأیید مالکیت دامنه عملاً درب باز برای link hijacking است.
چرا Expo Router کار را آسانتر میکند؟
در Expo Router نسخه ۴ که با SDK 55 منتشر شد، تمام مسیرهای فایلمحور بهصورت خودکار قابلیت deep link پیدا میکنند. یعنی اگر فایلی به نام app/product/[id].tsx داشته باشید، URLی مثل https://yourdomain.com/product/42 خودبهخود به آن صفحه نگاشت میشود. بدون نیاز به تنظیم دستی linking در React Navigation و بدون نگرانی برای همگام نگهداشتن دو منبع حقیقت.
صادقانه بگویم، این تنها دلیلی است که خودم بعد از سالها کار با React Navigation سراغ Expo Router رفتم.
پیشنیازها و نصب پکیجها
پیش از شروع، مطمئن شوید پروژهتان از Expo SDK 55 یا بالاتر استفاده میکند و یک Development Build دارید. یک نکته که اگر یاد نگیرید، یک هفته از عمرتان تلف میشود: Deep Linking در Expo Go بهطور کامل پشتیبانی نمیشود. حتماً Development Build بسازید.
npx expo install expo-linking expo-router expo-dev-client
npx expo prebuild --clean
eas build --profile development --platform all
پیکربندی پایه در app.json
فایل app.json یا app.config.ts خود را به این شکل تنظیم کنید. این پیکربندی هم scheme سفارشی برای OAuth و هم Associated Domains را پوشش میدهد:
{
"expo": {
"scheme": "myapp",
"ios": {
"bundleIdentifier": "com.example.myapp",
"associatedDomains": [
"applinks:app.example.com",
"applinks:www.example.com"
]
},
"android": {
"package": "com.example.myapp",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "app.example.com",
"pathPrefix": "/"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
},
"plugins": ["expo-router"]
}
}
توجه: در associatedDomains نباید پروتکل https:// را وارد کنید. این شاید رایجترین خطایی است که Universal Links را بیسروصدا از کار میاندازد. فقط applinks: را بهعنوان پیشوند نگه دارید و خلاص.
راهاندازی Universal Links در iOS
گام ۱: ساخت فایل apple-app-site-association
این فایل باید در مسیر https://yourdomain.com/.well-known/apple-app-site-association قرار گیرد. در پروژههای Next.js یا Expo Router web، آن را داخل public/.well-known/apple-app-site-association بگذارید. نکتهای که خیلیها فراموش میکنند: فایل را بدون پسوند .json ذخیره کنید.
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.myapp"],
"components": [
{
"/": "/product/*",
"comment": "Match product pages"
},
{
"/": "/user/*",
"?": { "ref": "?*" },
"comment": "Match user profile with ref param"
},
{
"/": "/admin/*",
"exclude": true,
"comment": "Exclude admin pages from app"
}
]
}
]
}
}
گام ۲: تنظیم Content-Type سرور
سرور باید این فایل را با Content-Type: application/json سرو کند. در Nginx کانفیگ به این شکل خواهد بود:
location = /.well-known/apple-app-site-association {
default_type application/json;
add_header Cache-Control "no-cache";
}
سه نکته حیاتی که هیچکدام را نباید از قلم بیندازید: فایل باید روی HTTPS سرو شود، هیچ redirect نباید داشته باشد (حتی همان trailing slash بیگناهی که Nginx اضافه میکند) و حجم آن نباید از ۱۲۸KB فراتر رود.
گام ۳: یافتن Team ID
برای پیدا کردن appID صحیح به Apple Developer Portal بروید، در بخش Membership، Team ID خود را کپی کنید و آن را با Bundle ID ترکیب نمایید. مثال: ABCDE12345.com.example.myapp.
راهاندازی App Links در اندروید
گام ۱: تولید Digital Asset Links
برای اندروید نیاز به فایل assetlinks.json دارید که در مسیر https://yourdomain.com/.well-known/assetlinks.json قرار میگیرد:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": [
"AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89",
"FE:DC:BA:98:76:54:32:10:FE:DC:BA:98:76:54:32:10:FE:DC:BA:98:76:54:32:10:FE:DC:BA:98:76:54:32:10"
]
}
}]
گام ۲: گرفتن SHA-256 Fingerprint
اینجا، دقیقاً اینجا، است که اغلب توسعهدهندگان چند ساعت از عمرشان را قربانی میکنند. شما باید فینگرپرینتهای هر دو کلید را اضافه کنید: کلید Upload (که با EAS تنظیم میشود) و کلید App Signing (که گوگل پلی مدیریت میکند).
# گرفتن فینگرپرینت کلید EAS Upload
eas credentials --platform android
# گرفتن فینگرپرینت از Google Play Console
# Settings > App integrity > App signing key certificate
# هر دو SHA-256 را در assetlinks.json قرار دهید
یک تذکر بسیار مهم: محیطهای Development، Preview و Production در EAS معمولاً SHA-256 های متفاوتی دارند. اگر برای production میسازید، حتماً فینگرپرینت Google Play App Signing Key را هم اضافه کنید — و گرنه کاربران واقعیتان لینکها را در مرورگر باز میبینند، نه در اپ.
گام ۳: اعتبارسنجی
adb shell pm verify-app-links --re-verify com.example.myapp
adb shell pm get-app-links com.example.myapp
خروجی باید شامل verified برای دامنه شما باشد. اگر none یا system دیدید، یعنی فرایند verification شکست خورده و باید سراغ چکلیست عیبیابی بروید (پایینتر میبینید).
دریافت پارامترها در Expo Router
پس از پیکربندی، در یک مسیر داینامیک میتوانید پارامترها را به این شکل بگیرید:
// app/product/[id].tsx
import { useLocalSearchParams, Stack } from 'expo-router';
export default function ProductScreen() {
const { id, ref } = useLocalSearchParams<{
id: string;
ref?: string;
}>();
return (
<>
<Stack.Screen options={{ title: `Product ${id}` }} />
<Text>Product ID: {id}</Text>
{ref && <Text>Referrer: {ref}</Text>}
</>
);
}
مدیریت لینک هنگام بسته بودن اپ
اگر اپ بسته است و کاربر روی لینک کلیک میکند، Expo Router بهطور خودکار به مسیر صحیح میرود. اما گاهی نیاز دارید قبل از انتقال، احراز هویت یا onboarding انجام شود. در این مواقع، +native-intent.tsx دوست شماست:
// app/+native-intent.tsx
export function redirectSystemPath({ path, initial }: {
path: string;
initial: boolean;
}) {
if (path.startsWith('/admin') && !isAuthenticated()) {
return '/login?redirect=' + encodeURIComponent(path);
}
return path;
}
این هوک قبل از رندر اولیه اجرا میشود و به شما اجازه میدهد مسیرها را بازنویسی کنید. توجه کنید که این روش فقط روی native کار میکند و در web در دسترس نیست.
گوشدادن به لینکها در زمان فعال بودن اپ
برای اپهایی که نیاز به analytics یا منطق سفارشی دارند، expo-linking بهترین گزینه است:
import * as Linking from 'expo-linking';
import { useEffect } from 'react';
export default function RootLayout() {
useEffect(() => {
const subscription = Linking.addEventListener('url', ({ url }) => {
console.log('Incoming URL:', url);
analytics.track('deep_link_opened', { url });
});
Linking.getInitialURL().then((url) => {
if (url) console.log('App opened with:', url);
});
return () => subscription.remove();
}, []);
return <Stack />;
}
تست Deep Linking در محیط توسعه
برای تست در Development Build، از ابزار uri-scheme استفاده کنید:
# تست custom scheme
npx uri-scheme open myapp://product/42 --ios
npx uri-scheme open myapp://product/42 --android
# تست Universal Link روی iOS
xcrun simctl openurl booted https://app.example.com/product/42
# تست App Link روی اندروید
adb shell am start -W -a android.intent.action.VIEW \
-d "https://app.example.com/product/42" com.example.myapp
برای تست Universal Link بدون deploy کردن وبسایت، فلگ --tunnel در Expo CLI زندگی شما را عوض میکند؛ این فلگ سرور توسعه را روی یک URL عمومی HTTPS forward میکند تا اپل بتواند فایل AASA را fetch کند.
رایجترین مشکلات و راهحلها
مشکل ۱: لینک در Notes یا Messages باز میشود ولی در Safari address bar نه
این رفتار طبیعی Universal Links است (هرچند به نظر باگ میرسد). اپل فرض میکند اگر کاربر آدرس را در Safari تایپ میکند، احتمالاً قصد دیدن نسخه وب را دارد. این یک باگ نیست؛ feature است.
مشکل ۲: unstable_settings باعث شکست Deep Linking میشود
یک باگ شناختهشده در Expo Router: اگر unstable_settings را export کنید (حتی بهصورت یک object خالی)، deep linking وقتی اپ در foreground یا background است کار نمیکند. اپ به جلو میآید اما به مسیر درخواستی نمیرود. اگر به این تنظیمات نیاز ندارید، خیلی ساده حذفش کنید و خلاص.
مشکل ۳: Verification اندروید پس از ۲۴ ساعت هم انجام نمیشود
چکلیست عیبیابی:
- آیا
autoVerifyدر intentFilter رویtrueاست؟ - آیا فایل
assetlinks.jsonبا Content-Type صحیحapplication/jsonسرو میشود؟ - آیا تمام SHA-256 ها (هم Upload Key و هم App Signing Key از Google Play) داخل فایل قرار دارند؟
- آیا package_name دقیقاً با Bundle ID اندروید مطابقت دارد؟ (دقیقاً، نه «تقریباً»)
مشکل ۴: کش AASA در iOS بعد از تغییر فایل بهروز نمیشود
اپل یک CDN دارد که فایلهای AASA را کش میکند. حذف اپ و نصب مجدد آن از طریق TestFlight یا Development Build، اپ را وادار میکند فایل را دوباره fetch کند. در iOS 14 و بالاتر، میتوانید از swcutil در ترمینال macOS برای پاک کردن کش استفاده کنید (که برای کسانی که هر روز با این موضوع سروکار دارند، نعمت است).
مشکل ۵: NavigationService آماده نیست
اگر deep link زودتر از بارگذاری اولیه navigation اجرا شود، ممکن است به خطا بخورید. راهحل: URL ورودی را در یک useState ذخیره کنید و پس از mount شدن navigation، آن را پردازش کنید. در صورت لزوم برای onboarding یا login، URL را در AsyncStorage یا MMKV نگه دارید تا پس از تکمیل فلو، آن را بازخوانی کنید.
الگوهای پیشرفته: Deferred Deep Linking
گاهی کاربر روی لینکی کلیک میکند، اپ نصب نیست، به App Store هدایت میشود و پس از نصب، باید مستقیم به همان محتوای اصلی منتقل شود. این الگو که Deferred Deep Linking نام دارد، نیازمند یک سرویس attribution مانند Branch، Adjust یا AppsFlyer است. در Expo میتوانید SDK این سرویسها را از طریق Config Plugins اضافه کنید:
npx expo install react-native-branch
// در app.json
"plugins": [
"expo-router",
["react-native-branch", {
"apiKey": "key_live_xxxxx"
}]
]
چکلیست نهایی پیش از انتشار
- فایل
apple-app-site-associationروی HTTPS بدون redirect سرو میشود. - فایل
assetlinks.jsonشامل تمام SHA-256 های production است. - روی دستگاه فیزیکی (نه شبیهساز) تست شده است.
autoVerifyدر intent filter اندروید فعال است.- الگوی
+native-intent.tsxبرای مسیرهای محافظتشده پیاده شده است. - Analytics روی deep link events تنظیم شده است.
- سناریوی fallback برای کاربران بدون اپ نصبشده مشخص است.
پرسشهای متداول
آیا Deep Linking در Expo Go کار میکند؟
پشتیبانی محدود است. Expo Go فقط با scheme exp:// کار میکند و نمیتواند Universal Links واقعی را تست کند. برای تست کامل، باید Development Build بسازید. این موضوع از SDK 53 به بعد سختگیرانهتر شده و از SDK 55، توصیه رسمی Expo استفاده الزامی از Development Build برای هرگونه تست linking است.
چرا Verify اندروید برای App Link من با شکست مواجه میشود؟
۹۰٪ مواقع به دلیل SHA-256 fingerprint اشتباه است. اگر برنامه شما از Google Play App Signing استفاده میکند (که گوگل بهطور پیشفرض فعال میکند)، فینگرپرینت کلیدی که با آن build میکنید با فینگرپرینت کلیدی که گوگل برای امضای نهایی استفاده میکند فرق دارد. باید هر دو را در assetlinks.json قرار دهید — همین یک نکته جواب اکثر تیکتهای پشتیبانی است.
تفاوت useLocalSearchParams و useGlobalSearchParams در Expo Router چیست؟
useLocalSearchParams فقط پارامترهای صفحه فعلی را برمیگرداند و وقتی به صفحه دیگری بروید، در صفحه قبلی بهروز نمیشود. useGlobalSearchParams پارامترهای فعالترین مسیر را برمیگرداند و میتواند در هوکهایی که در سراسر اپ استفاده میشوند مفید باشد، اما یک هزینه دارد: رندر اضافی. برای deep linking معمول، useLocalSearchParams را انتخاب کنید.
چگونه deep link را پس از login مدیریت کنم؟
بهترین الگو: در +native-intent.tsx اگر کاربر authenticate نشده، URL مقصد را بهصورت یک query parameter به /login اضافه کنید. پس از موفقیت login، با router.replace(redirect) به مسیر اصلی هدایت کنید. این روش از race condition بین navigation و auth state جلوگیری میکند (و بله، این race condition واقعی است؛ بارها دیدهام).
آیا میتوانم چندین domain برای Universal Links داشته باشم؟
بله. در associatedDomains میتوانید چندین applinks: اضافه کنید و هر domain باید فایل AASA خود را داشته باشد. در اندروید نیز میتوانید چندین intentFilter یا چندین data در یک filter قرار دهید. فقط یک محدودیت: اپل تعداد domain ها در یک اپ را به ۲۰ تا محدود کرده است.
جمعبندی
پیادهسازی Deep Linking با Expo Router در ۲۰۲۶ بهمراتب سادهتر از روشهای سنتی React Navigation است. اما همانطور که دیدید، همچنان نیاز به دقت در پیکربندی فایلهای verification، مدیریت SHA-256 و تست روی دستگاه فیزیکی دارد. اگر چکلیست بالا و الگوهای ارائهشده را دنبال کنید، میتوانید یک سیستم Universal Link و App Link قابل اعتماد بسازید که در تمام سناریوها — اپ بسته، باز یا حتی نصبنشده — رفتار درستی نشان دهد.