Skip to main content
The mobile package provides automatic generation of deep link verification files and handlers for Universal Links (iOS) and App Links (Android), enabling seamless app-to-web navigation.

Quick Start

import "github.com/go-mizu/mizu/mobile"

app := mizu.New()

// Add Universal/App Links middleware
app.Use(mobile.UniversalLinkMiddleware(mobile.UniversalLinkConfig{
    Apple: []mobile.AppleAppConfig{
        {
            TeamID:   "ABCD1234XY",
            BundleID: "com.example.app",
            Paths:    []string{"/share/*", "/invite/*"},
        },
    },
    Android: []mobile.AndroidAppConfig{
        {
            PackageName:  "com.example.app",
            Fingerprints: []string{"AA:BB:CC:DD:..."},
            Paths:        []string{"/share/*", "/invite/*"},
        },
    },
}))
This automatically serves:
  • /.well-known/apple-app-site-association for iOS
  • /.well-known/assetlinks.json for Android
  1. User taps a link to your domain
  2. iOS checks /.well-known/apple-app-site-association
  3. If app is installed and paths match, app opens directly
  4. Otherwise, Safari opens the URL
  1. User taps a link to your domain
  2. Android checks /.well-known/assetlinks.json
  3. If app is installed and verified, app opens
  4. Otherwise, browser opens the URL

Configuration

Apple App Configuration

type AppleAppConfig struct {
    // TeamID is the Apple Developer Team ID (10 characters)
    TeamID string

    // BundleID is the iOS app bundle identifier
    BundleID string

    // Paths are the URL paths to handle (supports * wildcards)
    Paths []string
}
Find your Team ID in the Apple Developer Portal.

Android App Configuration

type AndroidAppConfig struct {
    // PackageName is the Android app package name
    PackageName string

    // Fingerprints are SHA256 certificate fingerprints
    Fingerprints []string

    // Paths are the URL paths to handle
    Paths []string
}
Get your signing certificate fingerprint:
# For debug keystore
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android

# For release keystore
keytool -list -v -keystore your-release-key.keystore -alias your-alias

Full Configuration

app.Use(mobile.UniversalLinkMiddleware(mobile.UniversalLinkConfig{
    Apple: []mobile.AppleAppConfig{
        {
            TeamID:   "ABCD1234XY",
            BundleID: "com.example.app",
            Paths:    []string{"*"}, // All paths
        },
        {
            TeamID:   "ABCD1234XY",
            BundleID: "com.example.app.dev",
            Paths:    []string{"/dev/*"}, // Dev app for /dev/* only
        },
    },
    Android: []mobile.AndroidAppConfig{
        {
            PackageName: "com.example.app",
            Fingerprints: []string{
                "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99",
            },
        },
    },
    WebCredentials: []string{
        "com.example.app", // For password autofill
    },
    Fallback: "https://example.com",
}))

Generated Files

apple-app-site-association

{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCD1234XY.com.example.app"],
        "components": [
          {"/": "/share/*"},
          {"/": "/invite/*"}
        ]
      }
    ]
  }
}
[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": [
        "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
      ]
    }
  }
]
For simple single-app setups:
link := mobile.DeepLink{
    Scheme:   "myapp",           // Custom URL scheme
    Host:     "example.com",     // Universal link domain
    Paths:    []string{"/share/*", "/invite/*"},
    Fallback: "https://example.com",
}

app.Use(mobile.DeepLinkMiddleware(
    link,
    "ABCD1234XY",                    // Apple Team ID
    "com.example.app",               // iOS Bundle ID
    "com.example.app",               // Android Package
    "AA:BB:CC:...",                  // Android SHA256 fingerprint
))
Handle deep links with smart fallback to web:
// Redirect to app or web based on device
app.Get("/share/:id", mobile.DeepLinkHandler(
    "myapp",                    // URL scheme
    "https://example.com/share", // Web fallback
))

How It Works

  1. Parses device from mobile middleware context
  2. For mobile devices: renders HTML that attempts app deep link
  3. Falls back to web URL after 2.5 seconds
  4. For desktop/web: redirects directly to fallback

Generated HTML

For mobile devices, serves:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Opening App...</title>
</head>
<body>
<div class="loader"></div>
<p>Opening app...</p>
<p>If the app doesn't open, <a href="https://example.com/share">click here</a></p>
<script>
(function() {
  var timeout = setTimeout(function() {
    window.location = "https://example.com/share";
  }, 2500);
  window.location = "myapp:///share/abc123";
  window.addEventListener('blur', function() {
    clearTimeout(timeout);
  });
})();
</script>
</body>
</html>
app.Get("/invite/:code", func(c *mizu.Ctx) error {
    code := c.Param("code")
    device := mobile.DeviceFromCtx(c)

    // Validate invite code
    invite, err := db.GetInvite(code)
    if err != nil {
        return c.Redirect(302, "https://example.com/invalid-invite")
    }

    // Build deep link
    deepLink := fmt.Sprintf("myapp://invite/%s", code)
    fallback := fmt.Sprintf("https://example.com/invite/%s", code)

    // Mobile: try app first
    if device != nil && device.Platform.IsMobile() {
        return c.HTML(200, renderDeepLinkPage(deepLink, fallback))
    }

    // Desktop: show web page
    return c.Redirect(302, fallback)
})

func renderDeepLinkPage(deepLink, fallback string) string {
    return fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Opening App...</title>
    <style>
        body { font-family: system-ui; text-align: center; padding: 50px; }
        .button { display: inline-block; padding: 12px 24px;
                  background: #007aff; color: white; border-radius: 8px;
                  text-decoration: none; margin-top: 20px; }
    </style>
</head>
<body>
    <h2>Opening in App...</h2>
    <p>If the app doesn't open automatically:</p>
    <a class="button" href="%s">Open in Browser</a>
    <script>
        setTimeout(function() {
            window.location = "%s";
        }, 100);
        setTimeout(function() {
            window.location = "%s";
        }, 2500);
    </script>
</body>
</html>`, fallback, deepLink, fallback)
}

Path Patterns

Wildcard Matching

Paths: []string{
    "/share/*",      // Matches /share/anything
    "/user/*/posts", // Matches /user/123/posts
    "*",             // Matches all paths
}

Excluding Paths (iOS)

// In iOS, prefix with "NOT " to exclude
Paths: []string{
    "*",              // Match all
    "NOT /api/*",     // Except API routes
    "NOT /admin/*",   // Except admin routes
}
Handle deep links when app is installed after click:
// Store pending deep link
app.Get("/invite/:code", func(c *mizu.Ctx) error {
    code := c.Param("code")
    device := mobile.DeviceFromCtx(c)

    // Generate unique tracking ID
    trackingID := uuid.New().String()

    // Store pending deep link
    cache.Set(trackingID, &PendingDeepLink{
        Path:      "/invite/" + code,
        CreatedAt: time.Now(),
        DeviceInfo: device,
    }, 24*time.Hour)

    // Set cookie for web fallback
    c.Cookie(&http.Cookie{
        Name:     "pending_deeplink",
        Value:    trackingID,
        MaxAge:   86400,
        Path:     "/",
        HttpOnly: true,
        Secure:   true,
    })

    // Try to open app
    return mobile.DeepLinkHandler("myapp", "/install?track="+trackingID)(c)
})

// Called when app opens for first time
app.Get("/api/check-pending-deeplink", func(c *mizu.Ctx) error {
    device := mobile.DeviceFromCtx(c)

    // Find pending deep link for this device
    pending, err := db.FindPendingDeepLink(device.DeviceID)
    if err != nil || pending == nil {
        return c.JSON(200, map[string]any{"found": false})
    }

    // Mark as claimed
    db.ClaimPendingDeepLink(pending.ID)

    return c.JSON(200, map[string]any{
        "found": true,
        "path":  pending.Path,
    })
})

iOS Simulator

# Test Universal Link
xcrun simctl openurl booted "https://example.com/share/123"

# Test Custom Scheme
xcrun simctl openurl booted "myapp://share/123"

Android Emulator

# Test App Link
adb shell am start -a android.intent.action.VIEW \
    -d "https://example.com/share/123" \
    com.example.app

# Test Custom Scheme
adb shell am start -a android.intent.action.VIEW \
    -d "myapp://share/123" \
    com.example.app

Verify Configuration

# Check apple-app-site-association
curl https://example.com/.well-known/apple-app-site-association

# Check assetlinks.json
curl https://example.com/.well-known/assetlinks.json

# Validate AASA (Apple tool)
# https://search.developer.apple.com/appsearch-validation-tool/

# Validate Android (Google tool)
# https://developers.google.com/digital-asset-links/tools/generator

Client Implementation

iOS (Swift)

// In SceneDelegate
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else {
        return
    }

    handleDeepLink(url)
}

func handleDeepLink(_ url: URL) {
    guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
        return
    }

    switch components.path {
    case let path where path.hasPrefix("/share/"):
        let id = String(path.dropFirst("/share/".count))
        router.navigate(to: .share(id: id))

    case let path where path.hasPrefix("/invite/"):
        let code = String(path.dropFirst("/invite/".count))
        router.navigate(to: .invite(code: code))

    default:
        router.navigate(to: .home)
    }
}

Android (Kotlin)

// In AndroidManifest.xml
<activity android:name=".MainActivity">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" android:host="example.com" />
    </intent-filter>
</activity>

// In MainActivity
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    handleIntent(intent)
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    handleIntent(intent)
}

private fun handleIntent(intent: Intent) {
    val data = intent.data ?: return

    when {
        data.path?.startsWith("/share/") == true -> {
            val id = data.path?.removePrefix("/share/")
            navigateToShare(id)
        }
        data.path?.startsWith("/invite/") == true -> {
            val code = data.path?.removePrefix("/invite/")
            navigateToInvite(code)
        }
    }
}

Flutter (Dart)

import 'package:uni_links/uni_links.dart';

class DeepLinkService {
  StreamSubscription? _subscription;

  void initialize() {
    // Handle initial link
    getInitialUri().then(_handleUri);

    // Handle incoming links
    _subscription = uriLinkStream.listen(_handleUri);
  }

  void _handleUri(Uri? uri) {
    if (uri == null) return;

    final path = uri.path;

    if (path.startsWith('/share/')) {
      final id = path.replaceFirst('/share/', '');
      router.go('/share/$id');
    } else if (path.startsWith('/invite/')) {
      final code = path.replaceFirst('/invite/', '');
      router.go('/invite/$code');
    }
  }

  void dispose() {
    _subscription?.cancel();
  }
}

Best Practices

HTTPS Required

Both Universal Links and App Links require HTTPS. The verification files must be served over HTTPS.

CDN Considerations

If using a CDN, ensure the verification files are not cached aggressively:
app.Get("/.well-known/*", func(c *mizu.Ctx) error {
    c.Header().Set("Cache-Control", "max-age=3600") // 1 hour
    return next(c)
})

Handle All Cases

Always provide fallbacks for:
  • App not installed
  • Outdated app version
  • Invalid deep link paths
Track where users come from:
app.Get("/share/:id", func(c *mizu.Ctx) error {
    source := c.Query("utm_source", "direct")
    campaign := c.Query("utm_campaign", "")

    analytics.Track("deep_link_clicked", map[string]any{
        "path":     c.Request().URL.Path,
        "source":   source,
        "campaign": campaign,
        "platform": mobile.DeviceFromCtx(c).Platform.String(),
    })

    // Continue with deep link handling...
})

Next Steps