The site behind Spectra Launcher: a landing page for the launcher, 22 browser tools for Minecraft players and server owners, public player profiles, a badge system, modpack share links and an admin panel. English and Polish, server-rendered.
Production: https://usespectra.app
| Layer | Choice |
|---|---|
| Framework | Nuxt 4 (Nitro server, SSR + partial prerender) |
| UI | Nuxt UI 4, Tailwind, GSAP (v-reveal), three.js for skin previews |
| State | Pinia |
| Auth | better-auth — email + password, 2FA, username, Discord / Google / GitHub / Microsoft, Turnstile captcha |
| Database | Postgres (pg pool, schema created on boot) |
| Storage | Cloudflare R2 via aws4fetch |
| i18n | @nuxtjs/i18n, prefix_except_default — English at /, Polish at /pl |
| SEO | @nuxtjs/seo (robots, sitemap, OG images, schema.org, link checker) + nuxt-ai-ready |
| Analytics | self-hosted Umami, no cookies, no consent gate |
pnpm installCopy .env.example to .env and fill in at least DATABASE_URL and
BETTER_AUTH_SECRET, then:
pnpm devOn boot a Nitro plugin runs the better-auth migrations and ensureSchema(),
so an empty Postgres database is enough — no migration step to run by hand.
The production build does not read .env; pass the variables through the
environment (Docker, systemd unit, hosting panel).
| Variable | Needed for |
|---|---|
DATABASE_URL |
everything — Postgres connection string |
BETTER_AUTH_SECRET |
session signing |
NUXT_PUBLIC_SITE_URL |
canonical URLs, sitemap, OG images (defaults to the production domain) |
NUXT_PUBLIC_CONTROLLER, NUXT_PUBLIC_CONTACT_EMAIL |
the data controller and contact address printed in the legal pages |
ADMIN_EMAILS |
comma-separated allowlist for /admin; falls back to the owner's address |
SMTP_HOST, SMTP_USER, SMTP_PASSWORD, MAIL_FROM |
verification and password-reset mail (SMTP_PORT defaults to 465, SMTP_SECURE follows from the port) |
DISCORD_*, GOOGLE_*, GITHUB_*, MICROSOFT_* |
social sign-in |
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_URL |
avatars, badge art, shared modpacks |
TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY |
captcha on sign-up |
SPECTRA_INGEST_KEY |
soft key the launcher sends with anonymous telemetry |
DISCORD_BOT_TOKEN, DISCORD_GUILD_ID |
the Discord tab in the admin panel |
GITHUB_WEBHOOK_SECRET |
release webhook that announces new launcher versions |
NUXT_PUBLIC_UMAMI_SRC, NUXT_PUBLIC_UMAMI_ID |
analytics; omit and no script is injected |
R2_PUBLIC_URL must be the public CDN domain (https://cdn.…), not the S3
signing endpoint — files served from the signing endpoint 403 in the browser.
app/
├── components/ shared UI, skin viewers/editors, Discord builders
├── composables/ auth client, launcher version, tool SEO
├── middleware/ admin.ts — server-checked gate for /admin
├── pages/ routes (see below)
├── utils/ pure logic per tool, auto-imported
└── error.vue one page for 404 / 403 / 401 / 5xx
server/
├── api/ REST endpoints (accounts, friends, shares, badges, admin)
├── routes/render/ the public player-render image API
└── utils/ db pool, schema, auth, r2, badges, admin gate
i18n/locales/ en.json, pl.json — every string, both files always
test/ runnable checks, one per subsystem
| Path | What |
|---|---|
/ |
landing page |
/launcher |
launcher page: features, screenshots, all installers, FAQ |
/tools + /tools/<id> |
22 tools (colour codes, MOTD, gradients, banners, skin editor, poses, XP, coordinates, …) |
/badges, /badges/<slug> |
badge list and per-badge holders |
/u/<username> |
public profile: render, badges, capes, stats, activity graph |
/s/<code> |
shared modpack landing page |
/account |
profile, friends, security, connected accounts, privacy |
/admin |
stats, users, shares, badges, Discord — allowlisted accounts only |
/privacy, /terms, /cookies |
legal pages, rendered from i18n |
/secret |
badge code entry |
Public, cached, hotlinkable — the launcher and the profile pages both use it.
GET /render/<pose>/<player>/<crop>
<player> is a username or UUID. 29 poses (default, walking, sleeping,
hero, flexing, …) and the crops full, bust, face, head,
processed, barebones.
| Query | Range | Meaning |
|---|---|---|
size |
16–1024 | output size in pixels |
light |
flat, studio, soft, dramatic, sunset, night |
three-point lighting rig |
fx |
none, comic, statue, ice, hologram, plastic |
post effect |
rim |
0–200 | rim light strength, percent |
yaw, pitch |
±360 / ±89 | camera angles |
cape, voxel |
flag | draw the cape, draw 3D overlay layers |
nametag |
text or 1 |
floating name above the head |
/render/hero/makotopd/full?size=512&light=studio&rim=35&cape=1
Skins come from Mojang, resolved through a deduplicated cache — a page with thirty renders makes one profile lookup per player, not thirty.
The desktop launcher talks to these endpoints; the contract is documented on
the launcher side in docs/ARCHITECTURE.md.
| Endpoint | Purpose |
|---|---|
POST /api/auth/one-time-token/verify |
exchanges the token from the spectra://auth/… deep link for a bearer session |
POST /api/me/minecraft |
links the Minecraft profile (token is verified against Mojang, never stored) |
POST /api/me/activity |
daily launches and playtime — incremental, capped at one hour per call |
POST /api/presence, GET /api/notifications?playing=1 |
online status and "in game" |
POST /api/telemetry |
anonymous install-level counters, no account attached |
Account activity and anonymous telemetry live in separate tables on purpose and must never be joined — the privacy policy promises exactly that.
A badge row carries its own award rule, so a badge can be added after the fact
and back-fills whoever qualifies. Rules are one SQL predicate each over a shared
per-account stats view (server/utils/badges.ts): signup rank, join date,
account age, Minecraft nick length or pattern, launches, longest daily streak,
active days, playtime hours, friends, shared packs, downloads, plus manual and
secret-code awards. syncBadges() runs on sign-up, on Minecraft link, on every
launcher launch, on badge save, and from the panel's Przelicz button.
Every subsystem has one runnable check; none of them need a browser, and only two touch the network.
pnpm check:sql # no value is ever spliced into SQL
pnpm check:autoimport # every util export is visible to Nuxt's scanner
pnpm check:badges # every badge rule plans against the real database
pnpm check:seo # titles, canonical, hreflang, schema, og:image, sitemap, robots
pnpm check:poses # pose maths, crops, mitre seams
pnpm check:skin # skin parsing, legacy conversion, cape layoutsThe rest: check:social, check:username, check:discord, check:colors,
check:locator, check:smalltext, check:gradient, check:banner,
check:tellraw, check:armordye, check:coords, check:display,
check:potion, check:rank, check:head, check:xp, check:circle,
check:ticks, check:anim, check:motd, check:startfile.
check:seo needs a running server (pnpm dev), check:badges needs
DATABASE_URL. Everything else reads files and talks to nothing.
pnpm build
node .output/server/index.mjsThe build prerenders the tool and legal pages (both locales) and fails on a
prerender error, so a broken page cannot ship as static HTML. A Dockerfile is
included.
- No comments in application code.
.vue,.tsand.jsonsay what they do through naming; explanations belong in commit messages and here. The one exception is thesql-safe:marker, whichcheck:sqlreads. - Both locales, always. A new key goes into
en.jsonandpl.json. Never write an@inside a translation — vue-i18n treats it as linked-message syntax and eats it; the legal pages substitute%CONTROLLER%and%EMAIL%after translation for that reason. - Watch single-line exports. Nuxt's auto-import scanner is regex-based and
a one-line
export const X = { … }can hide the export directly below it. The build still succeeds and the page fails in the browser with "X is not defined" —pnpm check:autoimportcatches it. - Admin returns 404, not 403. The panel does not confirm its own existence to accounts that cannot use it.