Opening Web Invite Links Directly in the App with Expo Router

작성자

카테고리:

← 피드로
DEV Community · hiro@ · 2026-08-05 개발(SW)

hiro@

This article is an English translation of the original Japanese article.

In my club management app, I use the following invite URL for both web and iOS app:

https://squad-note.com/invite/{orgId}

Enter fullscreen mode Exit fullscreen mode

If the app is installed, Expo Router opens the invite screen in the app. If not, the web page displays. Using Universal Links lets me share a single URL rather than splitting it into web and app versions.

Expo Router File Structure

I place the invite screen as a dynamic route.

apps/mobile/src/app/invite/[orgId]/index.tsx

Enter fullscreen mode Exit fullscreen mode

The screen retrieves the orgId from the URL via useLocalSearchParams.

import { useLocalSearchParams, useRouter } from "expo-router";

export default function InviteScreen() {
  const { orgId } = useLocalSearchParams<{ orgId: string }>();
  const router = useRouter();

  const { data: org, isLoading } = api.organization.getPublic.useQuery(
    { id: orgId! },
    { enabled: !!orgId },
  );

  // Display invite content and execute join process
}

Enter fullscreen mode Exit fullscreen mode

When opened with /invite/abc, orgId receives abc. I also provide a page with the same path on the web side.

Setting a Custom Scheme

To handle app-specific URLs, I set a scheme in the Expo config.

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  scheme: "squadnote",
});

Enter fullscreen mode Exit fullscreen mode

This allows handling URLs like the following during development and authentication callbacks:

squadnote://invite/abc

Enter fullscreen mode Exit fullscreen mode

However, I use HTTPS for the invite URLs shared with users. Because custom schemes can be declared by different apps with the same scheme, I use Universal Links as the entry point to securely associate normal web URLs with the app.

iOS Associated Domains

In app.config.ts, I separate domains for production and development.

ios: {
  bundleIdentifier: IS_PROD
    ? "com.squadnote.app"
    : "com.squadnote.app.dev",
  associatedDomains: IS_PROD
    ? ["applinks:squad-note.com"]
    : ["applinks:dev.squad-note.com"],
}

Enter fullscreen mode Exit fullscreen mode

Adding the configuration alone does not make it work. I also serve apple-app-site-association from the web domain.

https://squad-note.com/.well-known/apple-app-site-association

Enter fullscreen mode Exit fullscreen mode

On the Next.js side, I placed it as a Route Handler.

apps/web/src/app/.well-known/apple-app-site-association/route.ts

Enter fullscreen mode Exit fullscreen mode

The response defines the appID combining the Apple Developer Team ID and Bundle ID, and the paths the app should open. To target only the invite link, I can narrow the range to /invite/*.

Shared URL

On the mobile side, I also share the HTTPS URL.

const inviteUrl = `${config.apiUrl}/invite/${id}?openExternalBrowser=1`;

await Share.share({
  message: `Invitation to ${orgName}n${inviteUrl}`,
});

Enter fullscreen mode Exit fullscreen mode

On iOS devices with the app installed, it opens the app as a Universal Link. Otherwise, it opens the web invite page.

openExternalBrowser=1 is a parameter used on the web side for LINE’s in-app browser. It is a separate issue from the Universal Links configuration, so I treat app association and web browser workarounds as separate concerns.

Verification Considerations

Universal Links work with the combination of an installed app, the domain’s AASA, and a signed build. Using only Expo Go does not provide the same verification as production.

I verify the following separately:

  • Tap URLs in messages with a TestFlight build installed
  • Open the same URL with the app deleted
  • Confirm production and dev domains are not confused
  • Confirm the AASA URL returns JSON without redirects
  • Test with users already in the organization and non-members

With Expo Router, paths and file structure correspond, so creating the invite screen inside the app was simple. The time-consuming part was not the app, but associating the domain with iOS.

References

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다