Vibe-coded な app の deep link bug は、たいてい退屈な input の問題から始まります。malformed な user ID、古い campaign URL、push payload に欠けた enum 値。そこから app がその input を trusted として扱い、happy-path logic の深いところで screen が crash します。

URL parameter はすべて user input です。deep link、push notification payload、shared URL、QR code。全部 untrusted です。Cursor で Vibe coding していると、Claude Code は happy path を綺麗に生成する。でも paranoid path は、こちらが明示しない限り生成しません。assume する。そして production は assumption を罰します。

Route Boundary で Zod Validation する

param は app に入った瞬間に validate する。そこを越えた先では、残りのコードは typed で safe な値だけを扱うようにする。

// app/(app)/profile.tsx
// Validate at the gate. Everything past this point is typed.

import { useLocalSearchParams } from 'expo-router'
import { z } from 'zod'

const profileParamsSchema = z.object({
  section: z
    .enum(['overview', 'security', 'preferences'])
    .default('overview'),
  userId: z.string().uuid().optional(),
})

type ProfileParams = z.infer<typeof profileParamsSchema>

export default function ProfileScreen() {
  const raw = useLocalSearchParams()
  const result = profileParamsSchema.safeParse(raw)

  if (!result.success) {
    return <ErrorScreen message='Invalid profile parameters' />
  }

  const { section, userId } = result.data
  return <ProfileContent section={section} userId={userId} />
}

ProfileContent が受け取る sectionuserId は、すでに properly typed な値です。この component は useLocalSearchParams のことを知らない。validate もしない。ただ良い data を受け取るだけ。これが、現実世界から garbage が飛んできても、AI-generated feature が壊れない Vibe-coded app の作り方です。

+native-intent: 入る前に悪い URL を落とす

Expo Router には、malformed、stale、malicious な native path を処理するための +native-intent.tsx があります。この file は provider が mount する前 に動く。ここでやるべきことは path の rewrite と bad input の reject だけです。

// app/+native-intent.tsx
// This is your firewall, not your business logic.

export function redirectSystemPath({
  path,
}: {
  path: string
}) {
  // Reject paths with suspicious patterns
  if (path.includes('..') || path.includes('//')) {
    return '/'
  }

  // Migrate old URL format to new format
  if (path.startsWith('/user/')) {
    const userId = path.replace('/user/', '')
    if (z.string().uuid().safeParse(userId).success) {
      return `/profile?userId=${userId}`
    }
    return '/'
  }

  return path
}

重要なのはここです。+native-intent はユーザーが logged in しているかどうかを確認できません。やるべきなのは path の sanitize だけ。business authorization を置くのは route guard であって、ここではありません。

Typed Routes: compile time でミスを止める

config で typedRoutes を有効にして、route 名の typo をユーザーに届く前に止める。

// app.config.ts
{
  experiments: {
    typedRoutes: true,
  }
}

// Now the router validates at compile time:
router.push('/profile')         // OK
router.push('/profle')          // TypeScript error
router.push({
  pathname: '/profile',
  params: { section: 'overview' },
})  // OK

typed routes は route 名の typo を捕まえる。Zod validation は bad parameter を捕まえる。この 2 つを組み合わせれば、多くの web router にはない safety net が手に入ります。

Autotomy Expo Starter Pack には、Zod validation pattern、typed route config、+native-intent handler が最初から入っています。deep link は動く。ユーザーは crash しません。