Short link generation, click analytics, link expiry, and OG preview pages for Go. Pluggable processors; in-memory, Redis, or SQLite storage; one dependency in the core.
go get github.com/yinebebt/deeplinkservice, err := deeplink.New(deeplink.Config{
BaseURL: "https://link.example.com",
Store: deeplink.NewMemoryStore(),
TemplateDir: "templates/default",
})
if err != nil {
log.Fatal(err)
}
service.Register(deeplink.RedirectProcessor{})
// Mount alongside your own routes.
mux := http.NewServeMux()
mux.Handle("/", service.Handler())
mux.HandleFunc("GET /hello", yourHandler)
log.Fatal(http.ListenAndServe(":8090", mux))Create a short link:
curl -X POST http://localhost:8090/shorten \
-H 'Content-Type: application/json' \
-d '{"type":"redirect","url":"https://example.com/docs","title":"Docs"}'Open the returned short_url in a browser.
Implement Processor:
type Processor interface {
Type() string
Process(ctx context.Context, link *Link) error
}For custom template data, also implement Previewer:
type Previewer interface {
Preview(link *Link) any
}See example/custom for a working custom processor with tests.
SQLite by default (zero infra). Redis is optional for multi-instance setups:
go run ./cmd/deeplink
# Redis:
docker compose up -d
DEEPLINK_STORE=redis go run ./cmd/deeplinkAny Store implementation works; the destination determines persistence.
All built-in stores carry the full feature set — link expiry and click
analytics included:
| Store | Import | Persistent | Analytics storage |
|---|---|---|---|
deeplink.NewMemoryStore() |
core | no | aggregate maps |
redisstore.New(client) |
deeplink/redisstore |
yes | aggregate hashes (HINCRBY) |
sqlitestore.New(dsn) |
deeplink/sqlitestore |
yes | per-visit event rows |
import "github.com/yinebebt/deeplink/sqlitestore"
store, err := sqlitestore.New("deeplink.db")The Redis and SQLite stores live in subpackages, so their drivers
(go-redis, modernc.org/sqlite) are only pulled in when you import them —
the core stays at a single dependency (go-nanoid). Need another backend?
Implement the Store
interface, including the two analytics methods (RecordEvents and Stats).
Set expires_at (RFC 3339, must be in the future) to make a link
self-destruct. After it passes, the link resolves as 404 and stores purge
it (Redis via a native TTL, others lazily on read):
curl -X POST http://localhost:8090/shorten \
-H 'Content-Type: application/json' \
-d '{"type":"redirect","url":"https://example.com","expires_at":"2026-12-31T23:59:59Z"}'Omit expires_at for a link that never expires.
Every resolved visit is captured off the redirect path and flushed in the
background — bumping the click counter and recording the event for
breakdowns, which every store supports. Query them at GET /stats/{shortID}:
{
"short_id": "aBcD…",
"clicks": 1280,
"by_platform": {"android": 700, "ios": 500, "web": 80},
"by_referrer": {"t.co": 410, "facebook.com": 260},
"by_day": {"2026-06-13": 640, "2026-06-14": 640}
}Platform, device, browser, OS, and referrer are derived from the request.
| Method | Path | Description |
|---|---|---|
| POST | /shorten |
Create a short link |
| PATCH | /{shortID} |
Update mutable fields on a link |
| DELETE | /{shortID} |
Soft-delete a link (3h grace) |
| GET | /{shortID} |
Preview page (or 302 redirect) |
| GET | /links |
All links across types (dashboard data source) |
| GET | /links/{type} |
List links by type |
| GET | /links/{type}/{shortID} |
Link detail with click count |
| GET | /stats/{shortID} |
Click analytics breakdowns |
| GET | /health |
Health check |
When any store URL is set (AndroidStoreURL, IOSStoreURL, WebFallbackURL), these are also registered:
| Method | Path | Description |
|---|---|---|
| GET | /preview/{shortID} |
Preview without auto-redirect |
| GET | /redirect |
App store redirect by platform |
| GET | /.well-known/ |
Static files from template dir |
For iOS Universal Links and Android App Links, place your apple-app-site-association
and assetlinks.json files in <TemplateDir>/.well-known/.
cmd/deeplink reads its configuration from environment variables — see
.env.example for the full list with defaults. Setting
DEEPLINK_API_KEY enables auth on the mutating endpoints, sent as
Authorization: Bearer <key> or X-API-Key.
The default templates live in templates/default/; copy them and point
TemplateDir at the copy to customize. They render the
preview metadata fields plus {{.ShortURL}},
{{.Lang}}, and the store-fallback URLs ({{.AndroidStoreURL}},
{{.IOSStoreURL}}, {{.WebFallbackURL}}).
The default link.html tries the destination first (so an installed app
opens via its Universal/App Link) and, after a short timeout, falls back to
the right app store by platform — with no store URLs it just forwards to the
destination.
Preview pages emit Open Graph, Twitter Card, and fediverse tags. Empty fields
are omitted, so scrapers never see content="".
| Field | Effect |
|---|---|
Title |
<title>, og:title, twitter:title |
Description |
description, og:description, twitter:description |
ImageURL |
og:image, twitter:image. PNG/JPG/WebP only (SVG fails most scrapers); 1200x630 recommended |
ImageWidth / ImageHeight |
og:image:width / og:image:height |
ImageAlt |
og:image:alt, twitter:image:alt |
OGType |
og:type (defaults to website). Setting article also emits article:published_time and article:modified_time |
Locale |
og:locale. Falls back to Config.Locale |
UpdatedAt |
og:updated_time. Set automatically on create |
| Field | Effect |
|---|---|
SiteName |
og:site_name |
Locale |
Default og:locale when Link.Locale is empty |
TwitterSite |
twitter:site (e.g. @example) |
FediverseCreator |
fediverse:creator (e.g. @user@instance.tld) |
Pages also emit robots: noindex,follow so short links don't compete with
the destination URL in search.
The React landing page and demo dashboard live on the web branch (not in main). Build and deploy that SPA separately against this API. Set DEEPLINK_ALLOWED_ORIGINS (and DEEPLINK_API_KEY for mutating routes) when the UI is cross-origin.
go test ./... # run tests
go run ./cmd/deeplink # standalone server (SQLite by default)