Deep Linking i .NET MAUI: Universal Links (iOS), App Links (Android) og Shell URIs (2026)

Komplet 2026-guide til deep linking i .NET MAUI: opsæt Android App Links med assetlinks.json, iOS Universal Links med AASA, og rout via Shell, inkl. migration efter Firebase Dynamic Links.

Deep Linking .NET MAUI Guide 2026

Opdateret: 11. august 2026

Deep linking i .NET MAUI lader dig åbne bestemte skærme i din app direkte fra en URL, enten fra et website (Universal Links på iOS, App Links på Android) eller fra en anden app via et custom URI-scheme. I 2026, hvor Firebase Dynamic Links er lukket ned, står du med tre teknologier at kombinere: assetlinks.json til Android, apple-app-site-association til iOS, og .NET MAUI Shell's URI-baserede routing til at parse parametre og navigere. Denne guide viser hele opsætningen med kode, du faktisk kan køre, og trækker på det, jeg har set gå galt på et halvt dusin produktions-apps.

  • Universal Links (iOS) og App Links (Android) er de eneste "verificerede" deep links, som Google og Apple accepterer i 2026. De kræver en signeret fil hostet på HTTPS på dit domæne.
  • .NET MAUI Shell bruger URI-formatet //route?param=value, og du registrerer ruter med Routing.RegisterRoute, før første navigation.
  • Firebase Dynamic Links blev lukket 25. august 2025. Deferred deep linking skal nu løses med Branch, Adjust, AppsFlyer eller en egen fingerprint-tjeneste.
  • På Android bruger du [IntentFilter]-attributter i MainActivity. På iOS tilføjer du Associated Domains-entitlements og håndterer ContinueUserActivity.
  • Test Universal Links med xcrun simctl openurl og App Links med adb shell am start. Browseren omdirigerer ikke pålideligt fra en simulator, så brug altid CLI-værktøjerne.
  • Custom URI-schemes (myapp://) fungerer stadig, men brug dem kun til app-til-app kommunikation, ikke til links delt i browsere eller e-mails.

Hvad er deep linking i .NET MAUI?

Deep linking er teknikken, hvor en URL åbner et bestemt sted i din app (f.eks. en produktside, en samtaletråd eller et betalingsflow) i stedet for bare at starte forsiden. I .NET MAUI er der reelt tre kategorier, og vi bruger dem forskelligt i produktion:

  • Verificerede web-links (Universal Links på iOS, App Links på Android). Formatet er en helt normal https://-URL, f.eks. https://appen.dk/produkt/42. Operativsystemet ejer beslutningen om, hvorvidt din app må åbne linket, og det bygger på en signeret fil hostet på dit domæne.
  • Custom URI schemes som myapp://produkt/42. De fungerer stadig, men Chrome og Safari blokerer dem i stigende grad fra almindelige web-sider. Vi bruger dem primært til app-til-app-kald (OAuth callback, SDK-integrationer).
  • Shell URIs, altså .NET MAUI's interne routing-format (//shell-route?id=42). Det er ikke et deep link i sig selv. Det er sådan appen internt navigerer, når et deep link kommer ind.

Ærligt talt har vi på flere teams undervurderet, hvor meget infrastruktur der ligger bag deep linking. Det er ikke bare kode i appen. Det er DNS, en fil hostet med korrekte HTTP-headers, entitlements, og en Shell-graf, som kan modtage vilkårlige URI'er uden at knække på ukendte ruter. Hvis I allerede har en solid Shell-navigation som fundament, er halvdelen af arbejdet gjort.

Android App Links er Googles verificerede deep-link-format. Systemet ved, at netop din app må åbne https://appen.dk/produkt/*, fordi du har hostet en fil på https://appen.dk/.well-known/assetlinks.json, som er kryptografisk bundet til din app's signerings-fingeraftryk. Uden den fil får brugeren en "vælg app"-dialog, eller browseren åbner linket.

1. Registrér intent filter i MainActivity

I .NET MAUI ligger Android-specifik kode under Platforms/Android/. Åbn MainActivity.cs og tilføj to [IntentFilter]-attributter, én til myapp:// og én til https://:

using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;

namespace MinApp;

[Activity(
    Theme = "@style/Maui.SplashTheme",
    MainLauncher = true,
    LaunchMode = LaunchMode.SingleTop,
    ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation
        | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize
        | ConfigChanges.Density)]

// Verificerede HTTPS App Links
[IntentFilter(
    new[] { Intent.ActionView },
    Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
    DataScheme = "https",
    DataHost = "appen.dk",
    DataPathPrefix = "/produkt",
    AutoVerify = true)]

// Custom scheme fallback (til app-til-app)
[IntentFilter(
    new[] { Intent.ActionView },
    Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
    DataScheme = "myapp")]
public class MainActivity : MauiAppCompatActivity
{
    protected override void OnCreate(Bundle? savedInstanceState)
    {
        base.OnCreate(savedInstanceState);
        HandleIncomingIntent(Intent);
    }

    protected override void OnNewIntent(Intent? intent)
    {
        base.OnNewIntent(intent);
        HandleIncomingIntent(intent);
    }

    void HandleIncomingIntent(Intent? intent)
    {
        var data = intent?.Data;
        if (data is null) return;

        // Videreleveres til .NET-siden (se Shell-sektionen længere nede).
        DeepLinkRouter.Handle(data.ToString()!);
    }
}

AutoVerify = true er nøglen. Den får Android til at hente assetlinks.json ved appens installation og markere dine intent filters som "verificerede". Uden det får brugeren en disambiguation-dialog hver gang.

2. Hent SHA-256 fingerprint fra din keystore

keytool -list -v -keystore release.keystore -alias min-app-alias | grep SHA256

Hvis du bruger Google Play App Signing (anbefalet), skal du hente fingerprint fra Play Console under "App integrity" → "App signing key certificate". Den nøgle, Google faktisk signerer med, er ikke nødvendigvis den samme som din upload-nøgle. Læs mere i vores guide til code signing af .NET MAUI, hvor vi går i dybden med begge kæder.

3. Host assetlinks.json på dit domæne

Filen skal ligge præcis på https://appen.dk/.well-known/assetlinks.json, serveres med Content-Type: application/json og være tilgængelig uden redirects. Format:

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.minvirksomhed.minapp",
    "sha256_cert_fingerprints": [
      "A1:B2:C3:D4:...:FF"
    ]
  }
}]

Verificér med Googles Statement List generator og tester. Det er den eneste kilde til sandhed, mens du debugger.

Apples pendant hedder apple-app-site-association (AASA). Filosofien er den samme som Androids, men mekanikken er anderledes. iOS henter AASA-filen via Apples CDN, når appen installeres, og resultatet caches ret aggressivt. Fejl her koster typisk et par timer, fordi cachen ikke invalideres uden re-installation. Jeg har brændt en hel eftermiddag på præcis dét scenarie.

1. Aktivér Associated Domains entitlement

I Platforms/iOS/Entitlements.plist tilføjer du:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>applinks:appen.dk</string>
        <string>applinks:www.appen.dk</string>
    </array>
</dict>
</plist>

I dit .csproj skal du sørge for, at entitlements-filen faktisk kobles på build-outputtet:

<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0-ios'">
  <CodesignEntitlements>Platforms/iOS/Entitlements.plist</CodesignEntitlements>
</PropertyGroup>

2. Host AASA-filen

Filen skal ligge på https://appen.dk/.well-known/apple-app-site-association (uden .json-endelse, uden redirects, med Content-Type: application/json). Format:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCD1234EF.com.minvirksomhed.minapp"],
        "components": [
          {
            "/": "/produkt/*",
            "comment": "Matcher enhver produkt-URL"
          },
          {
            "/": "/ordre/*",
            "?": { "ref": "?*" }
          }
        ]
      }
    ]
  }
}

appIDs er dit Team ID plus bundle identifier. Find Team ID under Apple Developer → Membership. Detaljerne omkring format er beskrevet i Apples Associated Domains-dokumentation.

3. Håndtér ContinueUserActivity i AppDelegate

I Platforms/iOS/AppDelegate.cs:

using Foundation;
using UIKit;

namespace MinApp;

[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
    protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();

    public override bool ContinueUserActivity(
        UIApplication application,
        NSUserActivity userActivity,
        UIApplicationRestorationHandler completionHandler)
    {
        if (userActivity.ActivityType == "NSUserActivityTypeBrowsingWeb"
            && userActivity.WebPageUrl is { } url)
        {
            DeepLinkRouter.Handle(url.AbsoluteString!);
            return true;
        }
        return false;
    }

    // Custom URI scheme (myapp://)
    public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options)
    {
        DeepLinkRouter.Handle(url.AbsoluteString!);
        return true;
    }
}

Håndtering af deep links med .NET MAUI Shell

Alle indgående links ender ét sted i .NET-koden. Vi centraliserer det i en DeepLinkRouter-klasse, der oversætter den indkomne URI til en Shell-navigation. Fordelen er, at både Androids OnNewIntent og iOS's ContinueUserActivity kalder den samme funktion.

using System.Web;

namespace MinApp;

public static class DeepLinkRouter
{
    public static async Task Handle(string url)
    {
        // Vent til Shell er klar (vigtigt for cold-start)
        while (Shell.Current is null)
            await Task.Delay(50);

        var uri = new Uri(url);
        var segments = uri.AbsolutePath
            .Trim('/')
            .Split('/', StringSplitOptions.RemoveEmptyEntries);

        var query = HttpUtility.ParseQueryString(uri.Query);

        switch (segments.FirstOrDefault())
        {
            case "produkt" when segments.Length >= 2:
                await Shell.Current.GoToAsync(
                    $"//produkt?id={segments[1]}");
                break;

            case "ordre" when segments.Length >= 2:
                await Shell.Current.GoToAsync(
                    $"//ordre?nummer={segments[1]}&ref={query["ref"]}");
                break;

            default:
                await Shell.Current.GoToAsync("//home");
                break;
        }
    }
}

På modtager-siden bruger vi [QueryProperty] til at binde URL-parametre til viewmodel-egenskaber:

[QueryProperty(nameof(ProduktId), "id")]
public partial class ProduktPage : ContentPage
{
    string? _produktId;
    public string? ProduktId
    {
        get => _produktId;
        set
        {
            _produktId = value;
            OnPropertyChanged();
            _ = LoadProduct(value);
        }
    }
    // ...
}

Husk at registrere ruten, før første deep link kan komme ind, typisk i AppShell.xaml.cs-konstruktøren:

public AppShell()
{
    InitializeComponent();
    Routing.RegisterRoute("produkt", typeof(ProduktPage));
    Routing.RegisterRoute("ordre", typeof(OrdrePage));
}

Vi ser jævnligt teams, der begynder med et custom URI-scheme, fordi det er hurtigere at sætte op, og så aldrig får migreret. Det er en fejl, hvis linkene skal deles i browsere eller e-mails. Her er hvornår hver teknologi giver mening:

EgenskabCustom URI (myapp://)Universal / App Links (https://)
Kræver et domæne, du ejerNejJa
Verificeret ejerskabNej. Enhver app kan claime schemeJa, via assetlinks.json / AASA
Fallback hvis app ikke er installeretFejl / tomtWebsiden loader normalt
Fungerer i e-mail-klienterOfte blokeretJa
Fungerer fra Safari / Chrome adresse-barKun via user gestureJa
Egnet til OAuth callbackJa, de facto standardJa, men overkill
Opsætningstid~5 minutter1–2 timer inkl. DNS og verifikation

Vores tommelfingerregel: brug Universal/App Links som primær kanal for alt, der deles med brugere. Behold et custom scheme udelukkende som fallback for OAuth-flows og SDK-integrationer, hvor det er kravsspecificeret (f.eks. bruger MSAL typisk msauth.<bundle-id>://).

Test og debugging af deep links

Test aldrig deep links ved at skrive dem i browserens adresse-bar på en emulator eller simulator. Browserne omdirigerer ikke pålideligt til apps under debugging, så brug altid platformens CLI-værktøjer, som simulerer et rigtigt link-klik.

Android (adb)

# App Link (https)
adb shell am start -W -a android.intent.action.VIEW \
  -d "https://appen.dk/produkt/42" com.minvirksomhed.minapp

# Custom scheme
adb shell am start -W -a android.intent.action.VIEW \
  -d "myapp://produkt/42" com.minvirksomhed.minapp

# Verificér App Link status
adb shell pm get-app-links com.minvirksomhed.minapp

Den sidste kommando er guld værd. Den viser, om Android faktisk har verificeret dine app links via assetlinks.json. Ser du verified, virker det. Ser du legacy_failure, er filen ikke tilgængelig, eller fingerprintet er forkert.

iOS (simulator + device)

# Simulator
xcrun simctl openurl booted "https://appen.dk/produkt/42"

# Fysisk enhed via Notes.app: skriv link, tryk længe, vælg "Åbn i MinApp"

# Tjek hvad Apples CDN har hentet
curl -sI https://app-site-association.cdn-apple.com/a/v1/appen.dk

Deferred deep linking uden Firebase Dynamic Links

Deferred deep linking er scenariet, hvor en bruger klikker et link, ikke har appen installeret, går i store, installerer og åbner appen (og gerne skulle lande på den rigtige side, ikke bare forsiden). Firebase Dynamic Links var i årevis den nemme løsning, men den blev lukket 25. august 2025. I 2026 har vi disse alternativer:

  • Branch.io. Mest udbredte migration fra FDL. Gratis tier op til 10.000 månedlige klik. SDK'et findes til Xamarin, og der er community bindings til .NET MAUI.
  • AppsFlyer OneLink. Betalt, men enterprise-standard for attribution kombineret med deep linking.
  • Adjust. Samme kategori som AppsFlyer, stærk på ad-attribution.
  • Egen løsning med App Clips (iOS) og Instant Apps (Android). Dyrere at bygge, men ingen ekstern afhængighed.
  • Clipboard-baseret fallback. Brugeren kopierer et link før installation, appen læser clipboard ved første start. Fungerer, men bryder iOS 14+ privacy-badges (der er en gul indikator hver gang).

Jeg har på tre projekter i 2026 migreret til Branch. Migreringen tager typisk 2–3 dage, hvis dine App Links allerede er på plads: Branchs SDK genererer bare den korte URL og sender brugeren videre til dit eget domæne, hvor din eksisterende Universal Link / App Link tager over.

Almindelige fejl og fejlfinding

Efter at have sat deep linking op på et halvt dusin .NET MAUI-apps er der et mønster af fejl, vi ser igen og igen.

App Link virker kun første gang på Android

Symptom: første installation fungerer, men efter en genstart eller re-installation kommer disambiguation-dialogen tilbage. Årsag: assetlinks.json-filen returnerer en redirect, eller Content-Type er text/html i stedet for application/json. Kør curl -I https://appen.dk/.well-known/assetlinks.json og verificér begge dele.

Universal Link åbner Safari i stedet for appen

Årsag nummer ét: brugeren klikker linket på det samme domæne, som det peger på. iOS betragter det som en intern navigation og åbner Safari. Løsning: send brugere fra push-notifikationer, e-mail eller sociale medier, ikke fra dit eget site. Årsag nummer to: brugeren har swipet linket op i Safari for at ignorere appen én gang, og Safari husker den præference. Løsning: brugeren kan resette ved at trykke længe på linket og vælge "Åbn i MinApp".

Shell.Current er null ved cold start

Deep links på Android kan ramme OnCreate, før MAUI's Shell er initialiseret. Det er præcis den bug, jeg selv ramte, da jeg shippede første version. Løsningen er polling-loopet i vores DeepLinkRouter ovenfor, eller endnu bedre: brug MessagingCenter eller en dedikeret TaskCompletionSource, som løftes, når Shell er klar. Sæt aldrig deep links til at kaste ved null. Queue dem.

Deep link fra push-notifikation kolliderer med normal navigation

Hvis din app allerede navigerer, når en push-notifikation lander, kan du få dobbelt-navigation. Vores guide til push-notifikationer i .NET MAUI viser mønstret med at bruge Shell.Current.CurrentState til at afgøre, om appen allerede står på den korrekte side.

Ofte stillede spørgsmål

Hvad er forskellen mellem deep links og universal links?

"Deep link" er den generelle betegnelse for en URL, der åbner et bestemt sted i en app. "Universal Link" er Apples specifikke, verificerede implementering, der bruger https://-URL'er og en AASA-fil på dit domæne. Androids tilsvarende hedder "App Links". Custom URI-schemes (myapp://) er også deep links, men de er ikke verificerede.

Hvordan tester jeg universal links på iOS-simulatoren?

Brug xcrun simctl openurl booted "https://ditdomaene.dk/sti" i terminalen, mens simulatoren kører. Undgå at skrive URL'en direkte i Safari på simulatoren. Det åbner ofte kun web-versionen, fordi iOS anser adressebar-navigation som en eksplicit browser-handling.

Kan .NET MAUI Shell håndtere deep links automatisk?

Delvist. Shell forstår sit eget URI-format (//route?param=value) via Shell.Current.GoToAsync, men det er stadig dit ansvar at fange den indgående platform-URL (Android intent, iOS ContinueUserActivity) og oversætte den til Shells format. Vores DeepLinkRouter-eksempel ovenfor viser mønstret.

Er Firebase Dynamic Links stadig et alternativ i 2026?

Nej. Firebase Dynamic Links blev officielt lukket 25. august 2025, og alle FDL-URL'er returnerer nu 404. Til deferred deep linking bruger vi Branch.io, AppsFlyer OneLink eller Adjust som de mest almindelige migrationsstier.

Hvorfor bliver mine App Links ikke verificeret på Android?

De fire almindeligste årsager: (1) assetlinks.json serveres med forkert Content-Type. Det skal være application/json. (2) Filen returnerer en HTTP-redirect før 200 OK. (3) SHA-256 fingerprintet i filen matcher ikke den nøgle, Google Play faktisk signerer med. (4) AutoVerify = true mangler i din [IntentFilter]-attribut. Kør adb shell pm get-app-links <pkg> for at se den præcise fejlstatus.

Priya Sharma
Om Forfatteren Priya Sharma

Cross-platform engineering lead who's shipped apps to millions on both Play Store and App Store. Believes shared codebases shouldn't mean shared mediocrity.