When a Bad Link Reaches Production
Deep-link bugs in vibe-coded apps usually start with boring input problems: a malformed user ID, an outdated campaign URL, a missing enum value in a push payload. Then the app treats that input as trusted and a screen crashes deep inside happy-path logic.
Every URL parameter is user input. Deep links, push notification payloads, shared URLs, QR codes — they’re all untrusted. When you vibe code with Cursor, Claude Code generates the happy path beautifully. It doesn’t generate the paranoid path unless you make it. It assumes. Production punishes assumptions.
Zod Validation at the Route Boundary
Validate params right where they enter the app. After validation, the rest of your code works with typed, guaranteed-safe values:
// 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 receives section and userId as properly typed values. It doesn’t know about useLocalSearchParams. It doesn’t validate. It just receives good data. This is how you build a vibe-coded app where AI-generated features don’t break when the real world sends them garbage.
+native-intent: Reject Malicious URLs Before They Enter
Expo Router gives you +native-intent.tsx for handling incoming native paths that might be malformed, stale, or malicious. This file runs before your providers mount. It should only rewrite paths and reject bad input:
// 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
}
The key insight: +native-intent cannot check if the user is logged in. It should only sanitize paths. Business authorization belongs in route guards, not here.
Typed Routes: Catch Errors at Compile Time
Enable typedRoutes in your config and catch misspelled route names before they reach users:
// 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 catch misspelled names. Zod validation catches bad parameters. Between them, you have a safety net that most web routers don’t provide.
The Autotomy Expo Starter Pack includes Zod validation patterns, typed route configuration, and +native-intent handlers pre-configured. Your deep links work. Your users don’t crash.