Published on

Implementing Universal and App Links in an Expo App with a Next.js Monorepo

Authors
  • avatar
    Name
    Ibrahim Motani

Tackling Deep Linking in a Multi-Site Setup

Deep linking specifically universal links for iOS and app links for Android can really elevate a mobile app by bridging the gap between web and native experiences. Recently, I worked on a project where the web side was a Next.js monorepo managing two distinct sites (say, for different regions), and the mobile app was a single Expo build that dynamically adapted to serve content from either.

The goal: Allow specific web URLs to open directly in the app, remapping paths as needed, while handling staging and production seamlessly. It sounded straightforward, but as with most integrations involving native platforms, there were hurdles. Let me walk you through the journey, from setup to the bugs I hit and how I resolved them.

Building the Foundation: Monorepo and App Architecture

The Next.js monorepo was structured with separate apps for each variant (e.g., /apps/site-a and /apps/site-b), sharing components and logic via /packages. This kept things DRY while allowing domain-specific customizations.

On the mobile side, Expo Router handled navigation. The app started by checking AsyncStorage for a persisted country selection (via a custom provider). If absent, it redirected to a country selector screen. This one app for all approach simplified maintenance but required careful env handling.

In app.config.js, I used environment variables to toggle between staging and production:

const isProduction = process.env.EXPO_PUBLIC_APP_VARIANT === "production";

return {
  expo: {
    // ... other config
    ios: {
      bundleIdentifier: isProduction ? "com.yourcompany.yourapp" : "com.yourcompany.yourapp-staging",
      associatedDomains: isProduction ? ["applinks:example.com"] : ["applinks:staging.example.vercel.app"],
    },
    android: {
      package: isProduction ? "com.yourcompany.yourapp" : "com.yourcompany.yourapp_staging",
      // Intent filters with path restrictions
    },
  }
};

Association files on the web side restricted links: For iOS AASA (apple-app-site-association):

{
  "applinks": {
    "details": [
      {
        "appID": "YOUR_TEAM_ID.com.yourcompany.yourapp",
        "paths": ["/collections/*", "/products/*", "/search", "/search?*", "/"]
      },
      {
        "appID": "YOUR_TEAM_ID.com.yourcompany.yourapp-staging",
        "paths": ["/collections/*", "/products/*", "/search", "/search?*", "/"]
      }
    ]
  }
}

Android needed assetlinks.json with SHA256 fingerprints.

Initial Approach: Custom Hook for Path Remapping

App routes used a /shop prefix, unlike web URLs. I created a hook to parse incoming links, validate domains, add /shop, and navigate.

The hook (useUniversalLinks.ts):

// Simplified early version
useEffect(() => {
  const normalizePath = (path: string) => {
    if (path.startsWith("/collections") || path.startsWith("/products") || path.startsWith("/search")) {
      return `/shop${path}`;
    }
    return path;
  };

  const handleURL = async (url: string) => {
    // Domain validation...
    const { path } = Linking.parse(url);
    if (path) {
      router.push(normalizePath(path));
    }
  };

  // Handle initial and events...
}, []);

It worked for background navigation but failed on cold starts for non root paths. Expo Router sets the deep link as the initial route; no matching file, no render.

Debugging and the Redirect Solution

Logs pointed to route mismatches. Solution: Add root level redirect files matching web paths.

For /collections/*:

// app/collections/[handle].tsx
import { useLocalSearchParams, router } from "expo-router";
import { useEffect } from "react";

export default function CollectionsRedirect() {
  const { handle } = useLocalSearchParams();
  useEffect(() => {
    router.replace(`/shop/collections/${handle}`);
  }, [handle]);
  return null;
}

Similar for products and search. Updated hook to just validate/lock, letting Router handle the rest.

For Android, added intent filters mirroring AASA, plus assetlinks.json verification.

Pitfalls and Fixes

  • Cold Start Hangs: Hook too late for initial routes redirects ensured valid files.
  • Android Browser Fallback: Missed debug fingerprint in assetlinks.json, used keytool to get it right.
  • Env Inconsistencies: Dynamic config in app.config.js prevented domain mismatches.
  • Testing Tip: Always verify cold vs. background; ADB for Android sims helped isolate issues.

Reflections: A Solid Win Despite the Challenges

This setup created a unified experience across web and mobile in a monorepo, with the app adapting on the fly. The file based routing and platform specific associations made it tricky but rewarding. If you're integrating similar links, start with redirects and thorough testing. Code here uses placeholders, adapt as needed.

See you next time 👋

References