publik.
Browse appsAppsAPIHow it works+ Publish a repoSupport builders
Browse appsHow it worksPublish a repoSupport buildersGet helpDevelopersGitHubPrivacy
© 2026 Publik
← All apps

OpenQR

A Free Tool that allows you to create QR codes for your Website and edit those…

not yet reviewed by publik

O

OpenQR

open source · on publik

Vote on OpenQR
0
Read the install guide→Open in GitHub↗

Compared with

B

Bitly

Paid

Make this yours→Fork OpenQR, change something, publish your version. About 30 minutes, no experience assumed.

Having trouble? Tell us

How to install OpenQR

Every step written out, for people who have never opened a terminal. Pick the setup you have.

  • How to install OpenQR on Mac →
  • How to install OpenQR on Windows →

README

Open in GitHub ↗

OpenQR

Dynamic QR codes without the subscription.

OpenQR is a self-hostable alternative to paid dynamic QR-code platforms. Create a QR code once, point it at any URL, and change the destination whenever you like — the printed code never needs to be regenerated. Scan analytics are built in and privacy-friendly by design.

QR code  →  https://your-openqr.com/r/a8F92k  →  https://example.com
                        (permanent)                  (changeable)

Table of contents

  1. What is OpenQR?
  2. Features
  3. Screenshots
  4. Local development setup
  5. Docker setup
  6. Database setup
  7. Environment variables
  8. Production deployment
  9. How dynamic redirects work
  10. How analytics work
  11. Project structure
  12. Troubleshooting

What is OpenQR?

A static QR code encodes one URL forever — change the URL and you have to reprint everything. A dynamic QR code encodes a short link owned by the platform: the link is permanent, but where it points can change at any time.

OpenQR gives you the dynamic part without the monthly fee and without trusting a third party with your traffic. It is a complete, runnable web application:

  • Next.js 16 + TypeScript App Router frontend and API
  • Tailwind CSS 4 + shadcn/ui design system, dark/light mode, fully responsive
  • PostgreSQL + Prisma ORM for data (SQLite for zero-config local dev)
  • Docker Compose for one-command self-hosting

Features

AreaWhat you get
Dynamic redirectsEvery QR code gets a permanent short code (/r/a8F92k); its destination is editable at any time
QR customizationForeground/background colors, export size (256–2048 px), error correction level (L/M/Q/H) with a built-in scannability guard
DownloadsPrint-ready PNG and vector SVG exports
AnalyticsScans over time, unique visitors, top countries, device types, browsers, operating systems, referrers — with Today / 7 days / 30 days / All-time filters
PrivacyNo cookies, no fingerprints, no raw IPs (salted SHA-256 hashes only, used solely for unique-scan counting)
AuthEmail + password registration/login, bcrypt hashing, DB-backed sessions, protected dashboard
SecurityZod validation, URL allow-listing, per-user authorization, rate limiting, secure cookies, CSRF-resistant server actions
Self-hostingSingle docker compose up -d, PostgreSQL included, automatic schema sync on boot

Screenshots

Placeholders — replace with real captures of your instance.

Landing pageDashboard
docs/screenshots/landing.pngdocs/screenshots/dashboard.png
QR detail pageAnalytics
docs/screenshots/qr-detail.pngdocs/screenshots/analytics.png

Docker setup

The easiest way to run OpenQR in production:

git clone git@github.com:your-username/openqr.git
cd openqr

cp .env.example .env
# Edit .env — set POSTGRES_PASSWORD, NEXT_PUBLIC_APP_URL and IP_HASH_SALT.

docker compose up -d

This starts two services:

  • app — the OpenQR Next.js application (built from the included Dockerfile)
  • db — PostgreSQL 16 with a persistent volume

On startup the app container syncs the database schema automatically (prisma db push, idempotent — it only applies what is missing). Disable with OPENQR_AUTOMIGRATE=false if you manage migrations yourself.

Then visit http://your-server:3000 (or whatever APP_PORT you set).

docker compose logs -f app   # follow logs
docker compose down          # stop (data survives in the postgres-data volume)
docker compose down -v       # stop and DELETE all data

Database setup

OpenQR ships two Prisma schemas with identical models:

FileProviderUsed for
prisma/schema.prismaSQLiteZero-config local development
prisma/schema.postgres.prismaPostgreSQLDocker / production self-hosting

The Dockerfile swaps the Postgres schema in at build time, so containers always run on PostgreSQL.

# SQLite (local dev)
bun run db:push

# PostgreSQL (when DATABASE_URL points at Postgres)
bun run db:push:postgres
bun run db:generate:postgres

Schema highlights — all hot paths are indexed:

model QRCode {
  shortCode String @unique        // redirect lookups
  userId    String
  @@index([userId])               // dashboard listing
  @@index([createdAt])
}

model ScanEvent {
  qrCodeId String
  createdAt DateTime
  ipHash   String?
  @@index([qrCodeId])             // per-code analytics
  @@index([createdAt])            // date-range filters
  @@index([qrCodeId, createdAt])  // combined queries
  @@index([ipHash])               // unique-scan counts
}

Environment variables

VariableRequiredDefaultDescription
DATABASE_URLyes—Postgres connection string in Docker (postgresql://user:pass@db:5432/openqr?schema=public) or a SQLite file for local dev (file:./db/custom.db)
NEXT_PUBLIC_APP_URLrecommendedderived from request headersPublic base URL used to build short links encoded in QR codes. Set it to the URL visitors actually reach
IP_HASH_SALTyes (prod)insecure defaultSecret salt for hashing IPs. Generate with openssl rand -hex 32. Changing it invalidates future unique counts only
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DByes (Docker)openqr / — / openqrCredentials for the compose db service
APP_PORTno3000Host port for the app container
OPENQR_AUTOMIGRATEnotrueRun prisma db push on container start

Never expose database credentials to the client: all database access happens in server-only modules (src/lib/db.ts and friends), and NEXT_PUBLIC_* variables are limited to non-sensitive values.

Production deployment

  1. Server requirements — any host running Docker (a 1 vCPU / 1 GB VPS is enough to start), or Node 20+/Bun with your own PostgreSQL.
  2. Configure — copy .env.example to .env. Set:
    • NEXT_PUBLIC_APP_URL=https://qr.yourdomain.com (must match what users' phones resolve — QR codes encode this host)
    • POSTGRES_PASSWORD and IP_HASH_SALT to long random strings
  3. HTTPS — put OpenQR behind a reverse proxy with TLS (Caddy, Nginx, Traefik, or a cloud load balancer). The app reads x-forwarded-proto/x-forwarded-host, so standard proxy headers work. Secure cookies turn on automatically when NODE_ENV=production and the request is HTTPS.
  4. Run — docker compose up -d --build.
  5. Backups — back up the postgres-data volume (docker run --rm -v openqr_postgres-data:/data -v $PWD:/backup alpine tar czf /backup/data.tgz /data).

Scaling notes:

  • Session state lives in PostgreSQL (not memory), so the app container is stateless and can be scaled horizontally.
  • Rate limiting is in-memory per instance. For multiple replicas, swap src/lib/rate-limit.ts to a Redis-backed implementation.
  • Analytics aggregation loads scan rows for the selected window; for very large histories add materialized counters or a time-series store.

How dynamic redirects work

  1. When you create a QR code, OpenQR generates a random 8-character base62 short code and stores it — it never changes, even when you edit everything else about the code.
  2. The QR image encodes {NEXT_PUBLIC_APP_URL}/r/{shortCode}. Because that URL is permanent, the printed code is permanent.
  3. When someone scans, GET /r/{shortCode} looks up the code by its unique index, records a scan event, and responds with a 302 redirect to the current destinationUrl.
  4. A 302 (temporary) redirect is deliberate: 301s are cached aggressively by browsers and proxies, which would break the "change destination anytime" guarantee. Responses are sent with cache-control: no-store.
  5. If a code is disabled, scanners see a friendly "paused" page; if it is deleted, a "not found" page. Nothing ever redirects to unvalidated URLs: destinations must be absolute http(s) URLs and cannot target localhost/private IP ranges.

How analytics work

Every redirect records a ScanEvent with:

FieldSourcePrivacy note
createdAtserver clock—
countryCDN/edge headers (cf-ipcountry, x-vercel-ip-country, …) when present2-letter country code only, null otherwise
deviceType / browser / osUser-Agent parsing (ua-parser-js)Coarse categories only
referrerReferer header, origin + path only, internal referrers droppedQuery strings (often containing PII) are stripped
ipHashHMAC-SHA256(IP, IP_HASH_SALT), truncatedThe raw IP is never stored and cannot be recovered; used exclusively to count unique visitors

What we deliberately do not store: raw IPs, full user-agent strings, cookies, fingerprints, or anything that identifies an individual. Because analytics are aggregates, they are GDPR-friendly by construction — no consent banner needed for counting.

Date filters (Today / 7 days / 30 days / All time) apply to every widget; hourly buckets are used for "Today", daily for the 7/30-day views and monthly for long all-time histories.

Project structure

src/
├── app/
│   ├── (auth)/login, register     # Authentication pages
│   ├── dashboard/                 # Protected app (overview, QR codes, analytics, settings)
│   ├── r/[shortCode]/route.ts     # Dynamic redirect endpoint
│   ├── api/qr/[id]/download/      # PNG/SVG export API
│   └── page.tsx                   # Landing page
├── actions/                       # Server actions (auth, QR CRUD)
├── components/                    # UI (shadcn/ui + feature components)
└── lib/                           # auth, db, queries, rate-limit, scan-tracking, qr, validation
prisma/
├── schema.prisma                  # SQLite (local dev)
└── schema.postgres.prisma         # PostgreSQL (production)

Troubleshooting

SymptomLikely cause & fix
"Invalid Server Actions request" on form submitYour proxy rewrites the Host header. Pass the original host through (proxy_set_header Host $host; / Caddy header_up Host {host}), or add your public domain to experimental.serverActions.allowedOrigins in next.config.ts
App container restarts in a loopThe database isn't reachable yet — check docker compose logs db and that POSTGRES_PASSWORD in .env matches what the db service uses
QR codes point at localhost:3000 in productionNEXT_PUBLIC_APP_URL is unset — set it to the public URL visitors actually reach and recreate the affected codes
Unique-scan counts reset to zeroIP_HASH_SALT changed — keep it stable; it is the key that makes repeat visitors recognizable
Countries show as "Unknown"No CDN geo header was present. Behind Cloudflare/Vercel the cf-ipcountry / x-vercel-ip-country headers are read automatically
docker compose down -v deleted everythingThat flag removes the postgres-data volume by design — restore from your backup (see Production deployment)

License

All rights reserved. You are free to run, modify, and self-host OpenQR for your own use.