Short link generation, click analytics, link expiry, and OG preview pages for Go, with MCP tools so AI clients can manage links too. 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.
The mcpserver subpackage exposes the same operations as MCP
tools, built on the official Go SDK.
| Tool | Does |
|---|---|
create_link |
Create a short link with optional preview fields and expiry |
get_link |
Destination, preview fields, and click count |
list_links |
Links with click counts, optionally by type (default 50) |
link_stats |
Click breakdowns by platform, referrer, and day |
delete_link |
Soft-delete a link |
list_types |
Link types create_link accepts |
Mount it next to the HTTP handler:
import "github.com/yinebebt/deeplink/mcpserver"
mux.Handle("/mcp", mcpserver.Handler(service, nil))It serves stateless streamable HTTP and does not authenticate. cmd/deeplink
serves it at /mcp behind DEEPLINK_API_KEY. To connect Claude Code:
claude mcp add --transport http deeplink https://link.example.com/mcp \
--header "Authorization: Bearer $DEEPLINK_API_KEY"| 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 mutating endpoints, on GET /links
and GET /stats/{id}, and on /mcp, sent as Authorization: Bearer <key> or X-API-Key.
Preview and redirect GETs stay public. Rotate the key if it has ever been
pasted into chat or committed.
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 demo UI is on the web branch.
go test ./... # run tests
go run ./cmd/deeplink # standalone server (SQLite by default)