Pixel-perfect clone of speed.cloudflare.com rebranded as Speed Tortoise — Next.js 16, Tailwind CSS v4, shadcn/ui + MapLibre GL + Lottie. Not affiliated with Cloudflare.
Independent speed test with real measurements (download/upload via /api/download & /api/upload, latency via /api/ping, packet loss via /api/packet-loss), network quality scoring, server map with multi-server support, CSV/PDF export.
- Next.js 16 — App Router, React 19, TypeScript strict,
output: "standalone" - Tailwind CSS v4 — oklch Kumo tokens (
--kumo-brand: #f6821f) - shadcn/ui + Base UI — Radix primitives
- MapLibre GL — Carto basemap +
MapMarker/MarkerTooltip - Lottie React —
speedtortoise(tortoise, 22fps, orange#f6821f) - Takumi PDF —
@takumi-rs/helpers+takumi-pdf
Requirements: Node.js 24+
npm install
npm run dev # http://localhost:3000
npm run build && npm startScripts: dev, build, start, lint, typecheck, check (lint + typecheck + build).
# .env or .env.local
NEXT_PUBLIC_SERVER_NAME=speedtortoise.local
# optional: FRONTEND_ENABLED=true (default)
docker compose up app --build # http://localhost:3000 (prod, standalone)
# health: GET /api/servers (also /api/ping, /api/server-info)docker-compose.yml app service builds Dockerfile (node:24-slim, standalone), exposes ${PORT:-3000}:3000, healthcheck: wget /api/servers.
Primary frontend lists all test servers via NEXT_PUBLIC_SPEEDTEST_SERVERS (JSON array). Secondaries run with frontend disabled so they expose only /api/* for the primary to hit cross-origin (CORS enabled).
1. Configure primary .env:
Only id, name, url are required — city/country/lat/lon/hostname are auto-fetched from each server's GET /api/server-info (server-side IP geolocation via ip-api.com → ipinfo.io fallback, no hardcoded coords).
NEXT_PUBLIC_SERVER_NAME=Primary — Male
FRONTEND_ENABLED=true
NEXT_PUBLIC_FRONTEND_ENABLED=true
# optional: IPINFO_TOKEN for higher rate limits (ipinfo.io)
# IPINFO_TOKEN=
# "" url = same origin (primary itself) — minimal form
NEXT_PUBLIC_SPEEDTEST_SERVERS='[
{"id":"primary","name":"Primary — Male","url":""},
{"id":"us-east","name":"US East","url":"https://us.example.com"},
{"id":"eu","name":"EU — Frankfurt","url":"https://eu.example.com"}
]'
# also accepted: NEXT_PUBLIC_SERVERS, SERVERS, SPEEDTEST_SERVERS
# full objects still work if you want to override auto-fetched location: {id, name, url, city, country, lat, lon, hostname}2. Host secondary (API-only, no UI):
# on secondary host .env
FRONTEND_ENABLED=false
NEXT_PUBLIC_FRONTEND_ENABLED=false
NEXT_PUBLIC_SERVER_NAME=US East
SERVER_NAME=us.example.com
# run only API (profile "api" exposes :3002)
docker compose --profile api up api --build
# or plain: FRONTEND_ENABLED=false docker compose up app --buildWhen FRONTEND_ENABLED=false, middleware.ts blocks /(.*) except /api/*//_next/* and returns 200 text/plain "Frontend disabled … /api/*"; the Next.js API still serves /api/ping, /api/download, /api/upload, /api/packet-loss, /api/server-info, /api/servers with Access-Control-Allow-Origin:*.
3. Frontend behavior:
src/lib/servers.tsgetServers()parsesNEXT_PUBLIC_SPEEDTEST_SERVERS(onlyid/name/urlrequired) +enrichServers()auto-fetches missingcity/country/lat/lon/hostnamefrom eachGET {url}/api/server-info(server-sideip-api.com→ipinfo.io);getDefaultServer()is first entry or enriched fallback (no hardcoded Male).src/hooks/use-speed-test.tsexposesservers,selectedServer,selectServer(id)(persistedlocalStorage:speedtest:selectedServer); all measurements useselectedServer.urlasbaseUrl(fetch(\${baseUrl}/api/ping`)withapiUrl()helper insrc/hooks/speed-test/measurements.ts`).src/components/ServerLocationPanel.tsxshows dropdown + markers for every server,MarkerTooltipshowsNEXT_PUBLIC_SERVER_NAME/hostnameon hover,MarkerLabelcity; map center follows selected server;src/app/page.tsxpassessp.servers/selectedServer/selectServerand shows API-only message whenNEXT_PUBLIC_FRONTEND_ENABLED=false.
4. Dev with Docker:
docker compose up dev --build # :3001 with volume mount, hot reloadEnv reference (.env.example):
NEXT_PUBLIC_SERVER_NAME=speedtortoise.local
SERVER_NAME=speedtortoise.local
# optional ipinfo token for server geolocation (auto-fallback to ip-api.com, no key needed)
# IPINFO_TOKEN=
FRONTEND_ENABLED=true
NEXT_PUBLIC_FRONTEND_ENABLED=true
# minimal: only id, name, url required — location auto-detected
NEXT_PUBLIC_SPEEDTEST_SERVERS='[{"id":"primary","name":"Primary — Male","url":""}]'# primary
NEXT_PUBLIC_SPEEDTEST_SERVERS='[...]' npm run build && npm start # :3000
# secondary API-only (different PORT)
FRONTEND_ENABLED=false PORT=3002 NEXT_PUBLIC_SERVER_NAME="US East" npm start # :3002Set url to secondary's public origin so browser can fetch it (CORS already enabled).
src/
app/
page.tsx # speed test + server selector, FRONTEND_ENABLED gate
layout.tsx # Speed Tortoise metadata, themeColor #f6821f
globals.css # Kumo tokens (--kumo-brand #f6821f, --brand-gradient)
api/
servers/ # GET /api/servers → {frontendEnabled, servers[]}
ping/ download/ upload/ packet-loss/ server-info/ export-pdf/ # CORS + dynamic
about/ privacy/ terms/ # internal routes (footer: /about /privacy /terms)
components/
SiteHeader.tsx # imports ../../public/lottie/speedtortoise.json (tortoise, fr 22)
SiteFooter.tsx # internal links only, no Cloudflare wordmark
ServerLocationPanel.tsx # multi-marker Map, select, MarkerTooltip (env server name)
ResultsPanel.tsx + ControlBar.tsx # Start/Pause/Resume single button + Retest
NetworkQualityPanel.tsx # Shimmer on value only
MeasurementPanels.tsx MetricTile.tsx Sparkline.tsx SectionTitle.tsx
icons.tsx # Kumo/Phosphor, no Cloudflare comment
ui/map.tsx # MapLibre, MapMarker/MarkerTooltip/MarkerLabel
pdf/ # Takumi PDF graphs
hooks/
use-speed-test.ts # 321-line orchestrator (re-exports STAGES)
speed-test/
stages.ts # STAGES (12)
measurements.ts # measureLatency/downloadActivity/uploadActivity… (baseUrl)
scoring.ts # computeAims
panels.ts # buildMeasurementPanels
format.ts # niceMax/makeTicks/fmtBits
parse.ts # parseSize/parsePacketCount…
index.ts # barrel for reuse
lib/
servers.ts # SpeedTestServer, getServers(), enrichServers() (ipinfo auto), isFrontendEnabled()
cors.ts # corsHeaders()
empty-result.ts # Unknown default — location auto-filled via /api/server-info
export-report.ts # CSV with BOM + "–"→"" fix
types/speedtest.ts
public/
favicon.ico og-card.png
lottie/speedtortoise.json + speedtortoise.lottie (tortoise, #f6821f, fr 22)
images/speedtortoise-*.png/gif
middleware.ts # blocks non-/api when FRONTEND_ENABLED=false
docker-compose.yml # app (prod) + dev + api (profile, FRONTEND false)
- Real measurements (not mock): download/upload streams, latency jitter, packet loss (50 concurrent), AIM scoring (video streaming/gaming/chatting)
- Single
Start/Pause/Resume+RetestinControlBar(sameh-9 bg-card ring-kumo-linestyle) - Map shows selected server dot (
bg-kumo-brand) + label + hover tooltip (NEXT_PUBLIC_SERVER_NAME), dropdown whenservers.length>1,MapControls showZoom - Exports: CSV (
\ufeffBOM,–→ empty) and PDF (takumi-pdf) - No consent modal;
Startis first control row
MIT