A Firebase Dynamic Links replacement built for a social commerce platform, later extracted and open-sourced as the deeplink Go library. It shortens URLs, detects the visitor’s platform (iOS, Android, web) and routes them to the right destination, generates Open Graph previews when links are shared, and tracks clicks with platform, referrer, and per-day breakdowns. Links can carry a TTL, and multi-language routing is supported for internationalized apps.
Background
This started as an internal service at ChipChip. We needed short links with OG preview pages for sharing content on social platforms (so Slack, Twitter, Telegram render a rich card), plus app store redirects for mobile users who didn’t have the app installed. The usual flow: a user clicks a link, and if the app is installed the OS opens it directly via Universal Links / App Links; otherwise the link falls through to a preview page and a redirect to the right destination: the Play Store or App Store for mobile, or a web fallback.
The original was tightly coupled to our infrastructure: internal auth middleware, specific template paths, deployment assumptions. I extracted the core and cleaned it up into a standalone library anyone can drop into a project, self-host, and keep their own link data.
Architecture
The core deeplink package exposes a Service you mount on any http.Handler. It has two paths: a write path that generates short links through pluggable processors, and a read path that resolves them to an Open Graph preview page. Persistence sits behind a small Store interface, and clicks are recorded off the hot path. The standalone server (cmd/deeplink) is one wiring of it.
flowchart TB
client["Client<br/>browser · crawler · mobile"]
os["Mobile OS"]
subgraph svc["deeplink.Service"]
write["POST /shorten<br/>create"]
read["GET /{shortID}<br/>OG preview page"]
redir["GET /redirect<br/>UA → store · opt-in"]
wk["GET /.well-known/<br/>AASA · assetlinks.json"]
end
proc["Processors<br/>RedirectProcessor · custom"]
tracker["Click tracker<br/>async · batched"]
store[("Store<br/>Redis · SQLite · in-memory")]
client -- "create" --> write
client -- "open link" --> read
client -. "no app · opt-in" .-> redir
os -. "verify association" .-> wk
write --> proc --> store
read --> store
read -. "click" .-> tracker --> store
The service is self-contained: it serves the .well-known/ association files itself, so no front proxy is needed. An installed app never reaches it: the OS catches the URL through that association (Universal Links / App Links) and opens the app directly.
Request Flow
GET /{shortID} is not a redirect on its own. It returns an HTML page carrying Open Graph tags so crawlers render a rich card. The default page then tries the destination (so an installed app opens via its Universal/App Link) and, after a short timeout, sends visitors without the app to the right store by platform, or just forwards to the destination when no store URLs are configured. A server-side GET /redirect does the same User-Agent routing for setups that prefer it, passing the original path through as a referrer.
sequenceDiagram
participant U as Visitor / Crawler
participant S as deeplink.Service
participant ST as Store
Note over U,S: Installed app opens via the .well-known association<br/>(Universal / App Links), the service is not hit
U->>S: GET /{shortID}
S->>ST: lookup link
ST-->>S: link payload
S-)S: enqueue click (async, batched)
S-->>U: OG preview page (Open Graph tags)
Note over U: crawler renders the card and stops.<br/>A visitor's page tries the app link, then after a<br/>short timeout falls back to the right app store
UA is used only to pick a store, a low-stakes guess: the trustworthy deep-open is the OS-level domain association, not the sniffing, and an unrecognized UA lands on the web fallback. Every visit is captured off the hot path through a buffered, batched tracker, so analytics never block the redirect (dropping rather than stalling under a burst), then aggregated into platform, referrer, and per-day breakdowns served at GET /stats/{shortID}.
Design Decisions
Embeddable core. The core package has no main. You create a Service, register processors, and call Handler() to get an http.Handler you mount alongside your own routes. The standalone server is one consumer of that API, not the API itself.
service, _ := deeplink.New(deeplink.Config{
BaseURL: "https://link.example.com",
Store: deeplink.NewMemoryStore(),
})
service.Register(deeplink.RedirectProcessor{})
mux := http.NewServeMux()
mux.Handle("/", service.Handler())
Pluggable processors. Different link types need different handling: a redirect link just validates and stores a URL, a product-share link fetches metadata from an API. Instead of branching per type, link handling goes through an interface:
type Processor interface {
Type() string
Process(ctx context.Context, payload *Link) error
}
The built-in RedirectProcessor covers the common case; anything custom implements the interface and registers itself.
Storage behind one interface. Redis, SQLite for a persistent file-backed option, and in-memory for tests and local dev, all behind one Store interface covering links, click counts, expiry, and analytics. Each backend stores breakdowns the way it’s good at: Redis as HINCRBY hash aggregates, SQLite as per-visit event rows, memory as aggregate maps. The Redis and SQLite drivers live in their own subpackages so they’re only pulled in when used, keeping the core at a single dependency; any other backend is one interface away.
Using It
POST /shorten
{
"type": "redirect",
"url": "https://example.com/products/123",
"title": "Product 123",
"image_url": "https://cdn.example.com/og/123.png"
}
# → { "short_url": "https://link.example.com/M9PoxcPb0D8d_HD7z" }
GET /{shortID}
# → OG preview page; the page hands off to the app or store, crawlers render the card
Links take an optional expires_at (Redis enforces it with a native TTL; other stores evict lazily), are soft-deleted with a grace window before purge, and are listed by type with cursor-based pagination.
Built at ChipChip, June 2024 – April 2026. Open-source library on GitHub with the API reference on pkg.go.dev. I also run an instance at link.yinebebt.com.