Self-hosted URL shortening platform with a built-in dashboard, multi-domain workspaces, team permissions, analytics, and a REST API.
- Short URLs with optional custom shortcode, notes, tags, teams, expiration, hit limits, and UTM templates
- Multi-domain in a single deployment (each domain maps to an organization/workspace)
- Authentication & access control via Better-Auth organizations + roles + API keys
- Teams inside an organization, with per-team policy enforcement (create/read/update/delete)
- Metrics collected on redirect (browser/device/os/language/referrer/geo/UTMs + internal vs external)
- Integrations
- Umami (server-side event tracking)
- VirusTotal API (domain validation / blacklist enforcement)
- API Reference UI with Scalar, serving both Snapp + Better-Auth schemas
- Theme override via a single
custom.cssfile (CSS variables → Tailwind tokens)
Snapp is multi-domain without running multiple instances.
- You configure a list of
hostsinsettings.yaml - Each
host.originmaps to an organization id:slugify(origin) - At request time, Snapp resolves the current host from
event.url.origin - The resolved host selects:
- the active organization
- the Better-Auth instance (cached per host)
- per-host options (signup, 2FA, homepage, limits, lowercase fallback, integrations, etc.)
On authenticated requests, if there is no activeOrganizationId, Snapp sets it to the organization derived from the current host.
If the user is not a member of that organization, access is denied (invitation flow is checked).
- Postgres database
- Docker (recommended)
services:
snapp:
image: uraniadev/snapp:latest
ports:
- '3000:3000'
environment:
DATABASE_URL: 'postgres://snapp:snapp_password@db:5432/snapp'
BETTER_AUTH_SECRET: 'change-me'
volumes:
- ./config:/app/config
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: snapp
POSTGRES_PASSWORD: snapp_password
POSTGRES_DB: snapp
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:Snapp reads configuration from config/settings.yaml.
Minimal example:
appname: Snapp
admin:
- email: admin@example.org
username: admin
hosts:
- origin: 'https://snapp.li'
options:
customRedirect: '/dashboard'
smtp:
enabled: false
# this will log outbound emailsOn startup, Snapp ensures:
adminusers exist (created if missing)- an organization exists per configured host (
id = slugify(origin)) - admin users are members of every organization (as
owner) - organization roles (owner/admin/member) are materialized with stored permissions
Teams exist inside an organization and are used to scope sharing/visibility and enforcement.
- Roles:
owner,admin,member - Teams: organization-defined groups (e.g. “Marketing”, “Support”)
Permissions are stored per organization role and keyed by team id:
createreadupdatedelete
Owners are not restricted by team policy (UI disables changing owner permissions).
When you toggle permissions in the UI, Snapp persists the permission graph and invalidates cached auth configuration so changes take effect immediately.
Snapp collects metrics during redirect resolution.
On a successful redirect (and when not blocked by secret checks), Snapp may store:
- timestamp
- browser / os / device / cpu
- language
- referrer
- geo (city / region / country) from IP (IPs are not saved)
- UTM payload attached to the URL
- internal vs external visit classification
- internal/external is computed by comparing the metric’s organization id to the URL’s owning organization id
The metrics dashboard supports:
- date range selection
- preset ranges (week / 2 weeks / month / 6 months / year)
- organization selection (admin-only cross-org view)
- UTM key filtering + per-key value filtering
- breakdowns (browsers, devices, OS, languages, referrers, countries, regions, cities, visitor organizations)
Integrations are configured per host (per domain).
If enabled, Snapp sends server-side events to Umami:
- redirects
- “not found” shortcodes
- invalid login attempts
- DB unavailable alerts
- secret invalid attempts
Configured in Settings → Integrations or in settings.yaml:
thirdparty:
umami:
url: 'https://umami.example.org'
websiteId: 'your-website-id'VTAPI is used to validate redirect targets and enforce blacklist rules (watchlist).
Configured in Settings → Integrations or in settings.yaml:
thirdparty:
vtapi:
apikey: 'your-virustotal-api-key'The API reference is served with Scalar at:
Scalar exposes two schemas from the same UI:
- Snapp OpenAPI (
/api/openapi.json) - Better-Auth OpenAPI (
/api/auth/open-api/generate-schema/)
The REST API uses Authorization: Bearer <api-key> and verifies permissions scoped to the current host organization.
Snapp supports runtime theme overrides via a single file:
config/custom.css(mounted into the container)
It is served at:
/custom.css(cached for 1 year)
The file is included at the end of the HTML document:
<link rel="stylesheet" href="/custom.css" />Recommended workflow:
- Generate a theme (Shadcn theme generator, etc.)
- Copy only
:root { ... }and.dark { ... } - Paste into
config/custom.css
This overrides the Tailwind-consumed CSS variables without changing component code or rebuilding the app.
Typical stack:
- SvelteKit (SSR)
- Postgres
- Drizzle ORM
- Better-Auth
- Scalar API Reference UI
- Oven Bun (but there has been some inconsistencies with layerchart, so we use node as for now).
Run locally with a Postgres instance and set:
DATABASE_URLBETTER_AUTH_SECRET
See LICENSE.
PRs welcome. Focus areas that matter most:
- correctness of permission enforcement
- redirect path robustness (secrets, blacklist, expiration, limits)
- metrics aggregation performance
- multi-domain safety (host resolution, org scoping)
- I18N Support validation
Some validation and code optimizations were assisted by AI, but Snapp remains an artisanal project. Most AI usage focused on generating documentation content and I18N translations.