← Blog

From opaque ids to human-readable paths: improving URL shareability and interoperability

Building GitHero taught me that URLs aren't just technical details—they're core to user experience. In this article, I share how GitHero’s URL structure evolved from using opaque inernal ids and GitHub node IDs to clean, human-readable paths, the challenges along the way, and why designing shareable, meaningful URLs is essential for most apps.


GitHero began as an alternative frontend for GitHub, tailored to the workflows of power users. The GitHub UI needs to accommodate a wide range of use cases—from casual users browsing open source repositories to developers filing issues or opening pull requests. But that’s not how a power user typically works.

Power users often focus on a small set of repositories they contribute to regularly. They benefit from features like saved queries—for example, "assigned issues" or "PRs waiting for my review." GitHero set out to prioritize these high-frequency workflows.

The Initial Approach: Private URLs

When I started designing GitHero, URL structure wasn’t a top priority. I initially went with a private URL scheme like this:

/u/:userId/query/:pageId

From day one, I wanted to support multiple GitHub accounts. Including a user or account identifier in the URL—similar to how Gmail handles accounts—made that easier. This allowed multiple tabs for different accounts, with the interface relying on the userId for context and authentication.

The pageId referenced a saved query—essentially a data structure that included metadata such as the target repository, applied filters, and so on.

When navigating to a specific issue or pull request, the pattern extended like this:

/u/:userId/query/:pageId/:nodeId

Here, nodeId is the globally unique ID for a GitHub object. Using nodeId allowed for simpler URL handling since I didn’t need to parse combinations like owner/repo/number, which vary by object type. (For example, commits and discussions don’t have numbers.)

This setup made queries straightforward. A single GraphQL query by nodeId could return the right fields based on object type. Mutations in GitHub’s API also use nodeId as input, and since nodeIds remain stable even if a repository or user is renamed, they were a reliable choice.

Complications Arise

As the app matured, new challenges emerged. When a user mentioned another user or linked to an issue, PR, or commit, those references used human-readable formats—like repo names, issue numbers, or commit SHAs—not nodeIds.

To resolve these links, I had to make extra network requests to look up the nodeIds. This introduced latency and added complexity, especially since everything else in the app depended on those IDs.

Worse, certain features required knowledge of the object type before making the query. For instance, to load a pull request efficiently, I needed to send three parallel requests: one for basic PR info, one for the timeline, and one for the merge box. But I should only fetch merge box data if it’s actually a PR—not just any node.

Technically, you can infer the type from the nodeId, but GitHub doesn’t recommend it. So I tweaked the URL scheme again:

/u/:userId/query/:pageId/:<typename:nodeId>

Not as clean, but it worked—until it didn’t.

Realizing the Limits of Opaque URLs

Over time, it became clear I was optimizing for one use case (power users with saved searches) while breaking others entirely. For example, if you just wanted to casually navigate to a repo or a specific section of one, GitHero forced you to create a saved query first.

And there were other UX downsides:

The Final Approach: Human-Readable, Shareable URLs

Private, opaque URLs helped me move fast early on. But I eventually realized that human-readable, predictable URLs improve UX, reduce complexity, and enable shareability.

So I dropped IDs from the URL entirely—no userId, no nodeId. Instead, GitHero URLs now use public-facing parameters, like so:

/repo/:owner/:repo/issues/:number

This URL format is clean and simple and also makes interoperability with GitHub easier, as you can easily transform GitHub URLs into GitHero URLs and vice versa.

But, of course, it’s not all that simple.

Mutations Still Require Node IDs

While you can fetch repository data using owner and name:

query {
  repository(owner: "octocat", name: "Hello-World") {
    id
    description
  }
}

If you're performing a mutation (e.g. updating an issue), you still need the repositoryId. So even when we don't use node Ids in the URL, we still query for them and use them for mutations.

References Can Be Ambiguous

GitHub uses the same #<number> syntax for both issues and pull requests. That means when a reference appears, we don’t know up front whether it points to an issue or a PR.

To avoid a chain of sequential requests, GitHero resolves this ambiguity by firing two GraphQL queries in parallel: one assuming it’s an issue, and another assuming it’s a PR. One fails, one succeeds—we use the result and discard the failed one.

Then we redirect the user to the correct, canonical URL, while reusing the successful request’s data so the UI doesn’t fetch it again.

Conclusion

Treat URLs as first-class citizens in your app. They aren’t just technical identifiers—they’re a core part of the user experience. URLs should be:

If you have to choose between opaque identifiers and meaningful parameters, always lean toward the latter. You’ll improve usability, reduce complexity, and save yourself (and your users) headaches down the line.