Every link that opens the app (a custom scheme, an iOS universal link, an Android App Link; cold start
or warm) passes one synchronous gate that opens only allow-listed routes with checked values. Verified
hosts flow from one contract into the app config of every variant and into the hosted
apple-app-site-association and assetlinks.json files. A checker replays hostile URLs through the app's
own gate. Verified on 2026-10-14 with Expo SDK 57 (expo 57.0.26), expo-router 57.0.24 and expo-linking
57.0.11 in Jest inside the real router and with npx expo config; not yet driven on a device.
Rules that must hold
- One gate for every incoming link:
src/app/+native-intent.tsx. It exportsredirectSystemPath, which hands every URL, cold (initial: true) and warm (initial: false), torouteIncomingLinkand returns only its answer. No other code subscribes to links: no'url'listener,getInitialURL,getLinkingURL,useLinkingURL,useURLorlegacy_subscribe. Why: Expo Router turns every file into a URL and routes whatever arrives; a second handler navigates twice or opens what the gate refused. (intent-gate,one-listener) - The gate is synchronous and total. The decision runs inside
attempt(...); a crash is reported to the error net and treated as a refusal; noasync,await,thenorfetchin it. Why: a throw there crashes the launch, a promise holds the first screen, and a rejection loses the link. Short links and referral codes resolve in a landing route that replaces itself. (intent-total,gate-behaviour) - An allow-list decides what a link may open.
LINK_ROUTESnames each linkable route by URL path (groups dropped, no catch-alls), and every entry is a real route file. Anything else opens the first screen. Why: without it any web page, QR code or app can open account deletion, checkout or a debug screen with any params. (link-routes,link-route-exists) - Values are checked by kind, query keys are listed, the href is re-encoded. Each dynamic segment has
a kind (
slug,uuid,int); unknown query keys (utm_source,fbclid) are dropped; a repeated kept key, a control character or a value over 256 characters is refused; no query key shadows a segment name; the gate returns an encoded path. The screen still parses its params with its valibot parser. Why: Expo Router decodes query values and joins them again unencoded (a%26bbecomes two keys), and in-app navigation never passes the gate. (link-routes,gate-behaviour) - A refused cold link lands on the first screen; a refused warm link changes nothing. Cold returns
/, warm returnsnull, and the refused URL is shown nowhere. Why: the router must render something at launch, but navigating home from a running app throws away unsaved work for a link that opens nothing. - A link into a guarded group waits, then opens once. Its entry names the
gateand builds a typed href inopen(); the guard's owner callsuseLinkGate(gate, isOpen); the first screen behind the guard callsuseOpenPendingLink(gate). Why: on SDK 57 a link into a closedStack.Protectedgroup lands on the first reachable screen and is lost after sign-in;+native-intentcannot read React state. (pending-link) - Custom schemes reach anyone; secrets ride only verified links. A route whose query or params carry a
token, password, OTP, magic or reset code is
verifiedOnly(http(s) of a verified host: Android verifies both schemes, iOS delivers only https). The secret is single-use, short-lived and confirmed on screen. Why: any app can register the app's scheme and receive the link; any app can still send a verified one. (link-secret) - Links open screens; they never act, and never forward. No route opened by a link mutates, buys,
deletes, signs in or changes settings on arrival; the person taps. No link's params reach
Linking.openURLor the browser, and none becomes an href (router.replace(params.next)): a return after sign-in goes through the pending-link inbox. Why: links are sent by strangers, prefetched by mail scanners and re-delivered after a reload; a forwarded URL is an open redirect with the app's trust. (open-redirect) - Debug and test-build routes are refused in production. The debug link entry is
{ path: '/debug/setup', query: 'any', testBuildOnly: true }. Why: a production link must not reach a screen the production bundle does not contain. (link-test-only) - One contract for verified hosts, applied to every variant.
contracts/deep-links.jsonlists hosts per variant;linkNativeConfig()turns them intoapplinks:<host>entries and one App Links filter (autoVerify,VIEW,BROWSABLE,DEFAULT, http and https, apathPatternper web path); the gate accepts exactly the hosts of the running config (publish each update with the build'sAPP_VARIANT). Why: when the entitlement, the filter and the gate disagree, links silently open the browser or a page the app refuses. (link-contract,native-links) - The web paths and the hosted files match the app. Every
webPathsentry opens a listed route; each host's AASA and assetlinks.json are generated bywrite-well-known.mjsand deployed by a person tohttps://<host>/.well-known/over HTTPS with no redirect. Why: iOS fetches the AASA through Apple's CDN at install and about weekly after, and Android verifies at install; a wrong file fails silently. (web-paths,well-known) - Real ids and fingerprints before a release. The assetlinks fingerprint is the key that signs what
people install (Play App Signing's, not the upload key's);
--releasefails onexample.comhosts,com.example.*ids and unfilled tokens. (ship-placeholder) - Every linked route is tested cold and warm. Jest through the real router with a
+native-intententry (cold viainitialUrl, warm via the router'surllistener), then named devices or MaestroopenLink. Why: Jest cannot prove the OS hands the link to the app.
Workflow
-
Read what the task needs:
Task Read first setup, a new linked screen, params, gates, cold vs warm, notification or OAuth links references/link-routing.md universal links, App Links, AASA, assetlinks.json, fingerprints, hosting references/universal-and-app-links.md trying links on a simulator, an emulator or in Maestro references/device-testing.md a FAIL line, the replay, configuration references/checker.md a React Navigation app references/react-navigation-linking.md -
Set up the gate once. Copy
templates/src/app/+native-intent.tsx,templates/src/core/links/,templates/src/features/deep-links/,templates/src/services/deep-links/, the two hooks intemplates/src/hooks/andtemplates/contracts/(every test with its file). The scheme per variant comes from app-variants-and-environment. With the debug menu (debug-menu-and-test-hooks), add its test-build entry toLINK_ROUTES. Runnpx jest src/core/links src/features/deep-links src/services/deep-links src/hooks --selectProjects unit. -
Link a screen. Add its entry to
LINK_ROUTESafterexamples/link-routes-tasks.ts: the URL path, a kind per segment, the query keys the screen reads,verifiedOnlyfor secrets. The screen parses its params (navigation-and-routing's params parser). Behind a guard: addgateandopen, calluseLinkGatewhere the guard is decided anduseOpenPendingLinkin the first screen's model. Add a case todeep-links-navigation.test.tsxfor the new route, cold and warm. -
Add verified links (when the app has a domain): fill
contracts/deep-links.json(hosts per variant,webPaths,appleTeamId,androidCertFingerprints), wiretemplates/scripts/deep-links/link-native-config.tsinto app.config.ts asexamples/app-config-deep-links.tsshows (a variant without hosts gets noassociatedDomainskey: an empty array still writes the entitlement), make a new native build, and hand off to expo-prebuild-and-config-plugins: refresh its native snapshot with its--writeoption after reading the drift (the new entitlement and MainActivity filter) and commit it with the change. Then runnode ${CLAUDE_SKILL_DIR}/scripts/write-well-known.mjs .. Stop and ask the developer to deploy each<wellKnownDir>/<host>/folder tohttps://<host>/.well-known/and to confirm the fingerprints from Play Console oreas credentials; uploading to a website is outward-facing. -
Try it on devices (references/device-testing.md): cold and warm on a named simulator and emulator,
pm get-app-linksfor Android verification; hand the flows to e2e-maestro (examples/maestro-deep-links.yaml). -
Run the checks:
node ${CLAUDE_SKILL_DIR}/scripts/check-deep-links.mjs .; fix everyFAILline (file, rule, fix) and rerun untilRESULT: PASS. In CI, alsonode ${CLAUDE_SKILL_DIR}/scripts/write-well-known.mjs . --check. Before a release, run the checker with--release.
Definition of done
-
src/app/+native-intent.tsxre-exportsredirectSystemPath; no other code listens to links. -
LINK_ROUTESlists every linkable route with kinds and query keys; secrets areverifiedOnly, debug routestestBuildOnly, gated routes haveopen(),useLinkGateanduseOpenPendingLink. - No linked screen acts on arrival; no link's params reach
openURLor the browser. - The template tests and a cold and warm case per linked route pass in Jest;
redirect-system-path.test.tsis what proves rule 5 (refused cold/, refused warmnull), which the checker cannot replay. - With a domain: the contract, the app config of every variant and the well-known files agree
(
write-well-known.mjs . --checkprintsRESULT: PASS); the developer deployed the files and sawverifiedinpm get-app-linksand the app open from a link in Notes. - Before a release:
node ${CLAUDE_SKILL_DIR}/scripts/check-deep-links.mjs . --releaseprintsRESULT: PASS. -
node ${CLAUDE_SKILL_DIR}/scripts/check-deep-links.mjs .printsRESULT: PASS(no SKIP lines: every target is this skill's own; an app that takes no input from links prints NOT APPLICABLE).
Anti-patterns
- A
+native-intentthat returnspath"for now". Every link then opens whatever it names. Return only whatrouteIncomingLinkdecided. Linking.addEventListener('url')oruseLinkingURL()in a screen. It double-handles links and skips the gate. List the route; read params in the screen model.- Resolving a short link inside
redirectSystemPathwithawait fetch. The launch waits on the network. Open a landing route that resolves and replaces itself. router.navigate('/')for a link that opens nothing while the app runs. It discards work; returnnull.- A password-reset or magic-link token on the custom scheme. Another app can receive it. Verified https links only, single-use, confirmed on screen.
- A link that completes, joins, pays or deletes on arrival. Show the screen; the person taps.
Linking.openURL(params.url). An open redirect; links carry ids, the app builds URLs insrc/config.- Refusing links with unknown query keys. Shared links gain
utm_*andfbclid; drop them instead. applinks:https://example.comor an https filter withoutautoVerify. Universal links never work; Android 12+ opens the browser.- The upload key's fingerprint with Play App Signing. Installs from Play fail verification; use the app signing key's.
- Editing the hosted AASA by hand. It drifts from the app; change the contract and regenerate.
- Sharing
tasks://...links. They open nothing without the app; share the https link. npx uri-scheme openorsimctl openurl bootedwith several simulators running. Name the device.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/link-routing.md | How links reach the router (read in source), the gate's decisions, the allow-list, origins, cold/warm/gated, links never act, OAuth, notifications, the debug link, share links, test recipes, pitfalls | Workflow steps 1-3 |
| references/universal-and-app-links.md | Scheme vs verified links, the contract pipeline, iOS entitlement and AASA, Android filter and assetlinks.json, fingerprints, hosting, variants, debugging verification | Workflow step 4 |
| references/device-testing.md | Cold and warm on named simulators and emulators, verified links on devices, Maestro openLink, Expo Go |
Workflow step 5 |
| references/checker.md | Every rule with its fix, the gate-behaviour replay, configuration, write-well-known, the self-test | Workflow step 6, on a FAIL |
| references/react-navigation-linking.md | Appendix: the same gate in a React Navigation 7 app | Only for a React Navigation app |
templates/src/app/+native-intent.tsx |
The gate's entry for Expo Router (re-exports redirectSystemPath) |
Workflow step 2 |
templates/src/core/links/ |
link-route.ts (kinds, the entry type, table checks), incoming-link.ts (URL parsing), route-incoming-link.ts (the decision), each with its test |
Workflow step 2 |
templates/src/features/deep-links/ |
link-routes.ts (the allow-list), link-origins.ts, app-link-policy.ts, redirect-system-path.ts, their tests and the router test deep-links-navigation.test.tsx |
Workflow steps 2-3 |
templates/src/services/deep-links/ |
pending-link-inbox.ts and link-gates.ts with tests |
Workflow steps 2-3 |
templates/src/hooks/ |
use-link-gate.ts and use-open-pending-link.ts with tests |
Workflow step 3 |
templates/scripts/deep-links/ |
link-native-config.ts (contract to associatedDomains and intentFilters, for app.config.ts) with its test |
Workflow step 4 |
templates/contracts/ |
deep-links.json (empty hosts) and its JSON schema |
Workflow steps 2 and 4 |
examples/link-routes-tasks.ts |
A full Tasks allow-list: shared, static, gated, invitation, verified-only, OAuth, paywall, debug | Workflow step 3 |
examples/app-config-deep-links.ts |
The app.config.ts lines that wire verified links per variant | Workflow step 4 |
examples/share-task-link.ts |
Building share links on the verified host in src/config |
When the app shares links |
examples/maestro-deep-links.yaml |
Cold, warm and Android verified links in a Maestro flow | Workflow step 5 |
scripts/check-deep-links.mjs |
Checker for every rule above, with the gate replay | Workflow step 6, before a release with --release |
scripts/write-well-known.mjs |
Writes or (--check) verifies each host's AASA and assetlinks.json |
Workflow step 4, in CI |
scripts/selftest.mjs |
Proves both scripts against the fixtures | After changing a script |
scripts/lib/link-facts.mjs |
This skill's shared facts: paths, config evaluation, origins, expected files | Never by hand unless changing the scripts |
scripts/check-lib.mjs |
Shared script library (synced; do not edit here) | Never by hand |
scripts/lib/source-scan.mjs |
Shared import resolution and test-file detection (synced) | Never by hand |
scripts/lib/app-modules.mjs |
Shared loader for the app's pure TypeScript (synced) | Never by hand |
scripts/lib/ship-placeholders.mjs |
Shared placeholder list for --release (synced) |
Never by hand |
assets/shared.json |
Declares the synced files | When adding a shared file |
tests/bases/ |
The fixture apps: app/ (six link routes, two verified hosts, development without hosts, planted installed skills) and plain/ (no link input) |
When adding a rule |
tests/fixtures/ |
The self-test cases per script: good, one planted bug per rule, NOT APPLICABLE, bad input | When adding a rule |
Related skills
navigation-and-routing- routes, guards (Stack.Protected), params parsers, the not-found route.app-variants-and-environment- one scheme, id and name per variant.auth-and-secure-storage- the session guard, the OAuth redirect route and PKCE.push-notifications,local-notifications- notification taps mapped to typed hrefs.debug-menu-and-test-hooks- the debug link and theTEST_ONLYgate.error-handling-typed-results-attemptand the error net the gate reports to.expo-prebuild-and-config-plugins- native config from app.config.ts; a rebuild after link changes.eas-build-and-submit,ios-release-testflight,android-release-play- the Associated Domains capability and signing keys.e2e-maestro- link flows on named devices;ios-simulator-build,android-emulator-build- the devices.web-support- serving the website and the well-known files with Expo Router web.expo-router(Expo's official skill) - the Expo Router API reference.