Workstation4 / Blog / iOS
iOS Universal Links Web

Trading a custom URL scheme for Universal Links.

Sharing a daily result felt like it should be the gentlest way into the app. A friend taps your score, the exact puzzle opens, and they get to play the same grid you did. My first version of that flow worked perfectly on my own device and quietly broke for anyone who did not already have the app. The cause was a custom URL scheme, and the fix was to drop it entirely in favor of Universal Links.

WordErase serves everyone the same themed grid each day, so comparing scores only matters if the link you share lands on the same puzzle. I wired the first version of deep linking with a custom URL scheme: an app-specific prefix that iOS hands straight to the app the moment a matching URL is tapped.

On my own device it worked nicely. Tap a shared score, and the app jumps to that day's board. The trouble only shows up on a device that does not have the app installed, which is exactly the device you most hope to reach. A custom scheme has no meaning to anyone but the app that registers it. When the app is missing, there is nothing for iOS to hand the URL to, so the link does nothing at all. No prompt, no web fallback, no App Store page. It just fails, and it fails silently.

That is a rough outcome for a share feature. The whole point of sharing a result is to reach people who are not playing yet. I was handing new players a link that was a dead end for them, and I only noticed because I happened to test on a device that had never seen the app.

Universal Links solve this by using a normal https URL instead of a private scheme. The link I generate now looks like https://worderase.com/puzzle/2025-10-25?score=150. The same address does double duty: if the app is installed, iOS routes the tap into the app; if it is not, the URL behaves like any other web link and opens a real page in the browser.

  • People who have the app jump straight to the right puzzle, same as before.
  • People who do not have the app land on a genuine web page that can show the puzzle's theme and point them toward installing.
  • The link is a plain web address, so it survives being pasted into Messages, Notes, and other apps that would never honor a custom scheme.

The trade is that Universal Links require a handshake between the app and the website. The marketing site has to serve an association file at /.well-known/apple-app-site-association so iOS trusts that this domain is allowed to open this specific app. The file is small, must be served as application/json over HTTPS, and pins the app by its team-prefixed bundle identifier:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "T27LPTQA4U.com.hertzelle.WordErase",
        "paths": ["*"]
      }
    ]
  }
}

I started with a narrow paths entry of /puzzle/*, since that is the only route I share. I widened it to a * catch-all so that any worderase.com link opens the app when it is installed, and lets the app itself decide what to do. That choice only works because the app handles unexpected URLs gracefully, which I will come back to.

Broadening the handler

Moving off the custom scheme let me simplify the app side rather than complicate it. In SwiftUI, the link arrives through .onOpenURL on the root scene. Instead of recognizing one rigidly formatted custom URL, the handler now accepts any worderase.com URL, pulls the date out of the path components, and falls back sensibly when there is nothing useful to parse.

private func handleIncomingURL(_ url: URL) {
    // Only trust our own domain.
    guard url.host == "worderase.com" || url.host == "www.worderase.com" else { return }

    // Expect /puzzle/2025-10-25
    if url.pathComponents.count >= 3, url.pathComponents[1] == "puzzle" {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd"
        if let date = formatter.date(from: url.pathComponents[2]) {
            puzzleManager.loadPuzzle(for: date)
            return
        }
    }

    // Anything else (root, bad date, marketing page): just open today.
    puzzleManager.loadTodayPuzzleImmediately()
}

A couple of details mattered more than they look. The host check has to cover both the bare domain and the www variant, because a link can arrive either way depending on how someone pasted it. url.pathComponents includes a leading "/" as element zero, so the puzzle segment is at index 1 and the date at index 2, which is why the guard checks for at least three components. And the final fallback is deliberate: with the * catch-all in the association file, the app will open for URLs it does not specifically recognize, so the safe behavior is to drop the player onto today's board rather than do nothing.

A simpler share sheet

The share flow got simpler too. Earlier it juggled several activity items; now it presents a single UIActivityItemSource that carries the link plus an LPLinkMetadata object, so Messages and social apps render one rich preview card with the date, score, and a generated image. One URL, one preview, one destination that works whether or not the recipient has the app. The metadata sets originalURL and url to the same Universal Link and attaches a rendered score graphic through an NSItemProvider, with a star icon when the share is a perfect score. Fewer branches in the share path meant fewer ways for the feature to behave differently than I expected.

Guarding against spoilers

Because every day is the same shared puzzle, a link points at a date. That raised a question the custom scheme never forced me to answer: what happens if someone hand-edits a link to a day that has not been published yet? I did not want a crafted URL to leak an unpublished grid and spoil a puzzle before its day arrives. The web hint pages are generated from the same puzzle data, so this is a real exposure, not a hypothetical.

The site is a static Astro build, and I guard the future in two layers. At build time, getStaticPaths only emits pages for dates on or before the day of the build, so a future date is never turned into HTML in the first place. Then, as a belt-and-suspenders runtime check, the page compares the requested date against today and refuses to render the answer if it is in the future.

const today = new Date().toISOString().split('T')[0];

// Build time: never generate future pages.
return files
  .map(file => file.replace('.json', ''))
  .filter(date => date <= today)
  .map(date => ({ params: { date } }));

// Render time: hide the hint for any future date.
const isFuturePuzzle = date && date > today;

Comparing the dates as yyyy-MM-dd strings is a small trick worth noting: that format sorts lexicographically the same way it sorts chronologically, so a plain string comparison is enough and I never have to reason about time zones or parsing. The one operational cost is that the site has to rebuild each day so the new day's page exists, which I handle on a schedule rather than by hand.

A share link should work for the person who has never heard of you. That single requirement is what moved me off custom schemes.

What I took away

  • Custom URL schemes are fine for in-app routing, but they are a poor choice for anything you hand to a stranger, because they fail silently when the app is absent.
  • Universal Links cost a little setup, an association file and a verified domain, and in return give you one https URL that serves installed and not-yet-installed users alike.
  • Accepting any domain URL and parsing intent out of pathComponents, with a graceful fallback to today's puzzle, turned out more flexible and more robust than matching one rigid scheme.
  • When a link encodes a date, validate on the server. Filtering future pages at build time and again at render time means a hand-built URL cannot reveal something that is not public yet, and string-sorted yyyy-MM-dd dates make that comparison trivial.

The feature looks identical to someone who already plays. The difference is everyone else: the share link is no longer a quiet dead end, it is a real way in.

W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →