Slice any image into XYZ map tiles — entirely in your browser, powered by Rust and WebAssembly.
Tileforge takes a single large image and slices it into a directory tree of small square tiles organized as {zoom}/{x}/{y}.png — the standard XYZ tile format used by Leaflet, Mapbox GL, OpenLayers, and every major mapping library.
No server-side processing. The web UI runs the entire pipeline in a Web Worker using compiled-to-WASM Rust. Your images never leave your machine.
There's also a native CLI for batch processing and scripting.
- Browser-based — drop an image, configure, download a ZIP of tiles. Zero server uploads.
- Server-side processing — Pro users can offload processing to a native Rust API for larger images and higher zoom levels.
- Rust + WebAssembly — native-speed image processing compiled to WASM via
wasm-pack. - Three processing strategies — automatically selected based on image size:
Strategy When Memory Naive Small images (< 256 MB decoded) Full decode + resize per zoom Streaming PNG Large PNGs Row-by-row decode, never holds full image Strip extraction Large JPEG/WebP Full decode, but no per-zoom resized copies - Flat and Mercator projections — flat/equirectangular for fictional maps and artwork; Web Mercator (EPSG:3857) for real-world geographic maps from equirectangular sources.
- ZIP and PMTiles output — download tiles as ZIP or as a single PMTiles archive for efficient web serving.
- Single-pass processing — TeeWriter generates ZIP + PMTiles simultaneously in one pass over the tiles.
- PMTiles preview — tileset detail page streams only visible tiles via HTTP range requests instead of downloading the full archive.
- Thumbnail generation — worker auto-generates a 480px JPEG thumbnail for each tileset.
- Interactive tile preview — rendered with Leaflet directly from in-memory tiles. Available on both the home page (after processing) and tileset detail pages.
- Tileset gallery — browse public tilesets with thumbnail previews. Manage your own tilesets with rename, visibility toggle, and delete.
- GitHub OAuth — sign in with GitHub. JWT-based auth shared between Next.js and the Rust API.
- API keys — Pro users can generate
tf_...bearer tokens for programmatic access to their tilesets. - Notifications — in-app notification system with DB-backed storage for Pro users.
- Rate limiting — per-IP rate limits on all API endpoints via Redis.
- Account management — deactivation with 30-day grace period and admin purge.
- Configurable — tile size (128/256/512), min/max zoom, projection type.
- Pyramid builder — lower zoom levels are built by merging 4 tiles into 1, cascading from max zoom down to zoom 0.
- CLI tool — native binary for scripting and batch jobs.
- Dark mode — green-tinted dark theme with amber accents (light mode supported too).
tileforge/
├── Cargo.toml # Workspace root
├── crates/
│ ├── core/src/ # Tiling engine (Tiler, ZipTileWriter, PmTilesTileWriter, TeeTileWriter)
│ ├── api/src/ # HTTP API (axum)
│ │ ├── main.rs # Entrypoint, service init, router wiring
│ │ ├── config.rs # AppConfig (env var parsing)
│ │ ├── state.rs # AppState, shared helpers (require_db, S3 cleanup)
│ │ ├── error.rs # ApiError enum + HTTP status mapping
│ │ ├── auth.rs # JWT/API key auth middleware, extractors
│ │ ├── rate_limit.rs # Tiered rate limiting (anon < free < pro)
│ │ ├── s3.rs # S3 bucket initialization
│ │ └── handlers/ # Route handlers
│ │ ├── tiles.rs # Tile processing (sync/async), SSE progress, downloads
│ │ ├── tilesets.rs # Tileset CRUD, PMTiles presigned URLs
│ │ ├── user.rs # Current user, account deactivation
│ │ ├── notifications.rs # Notification CRUD
│ │ ├── api_keys.rs # API key management (Pro only)
│ │ └── admin.rs # Broadcast notifications, purge accounts
│ ├── worker/src/main.rs # Background worker — async jobs, thumbnail generation
│ └── wasm/src/lib.rs # WASM bindings for crates/core
├── cli/src/main.rs # Native CLI binary
└── web/ # Next.js 16 + Tailwind v4 + shadcn/ui
├── app/
│ ├── layout.tsx # Root layout, navbar, SEO metadata
│ ├── page.tsx # Main UI: drop zone, config, progress, download
│ ├── gallery/page.tsx # Public tileset gallery with thumbnails
│ ├── my-tilesets/page.tsx # Authenticated user's tilesets
│ ├── tilesets/[slug]/ # Tileset detail: metadata, PMTiles preview, code snippets
│ ├── settings/page.tsx # User settings: API keys, notifications, account
│ └── billing/page.tsx # Stripe billing portal
├── components/
│ ├── tile-preview.tsx # Leaflet-based in-memory tile viewer (ZIP)
│ ├── pmtiles-preview.tsx # Leaflet tile layer backed by PMTiles range requests
│ ├── api-key-card.tsx # API key generation and display
│ ├── notification-panel.tsx # Notification dropdown panel
│ ├── notification-context.tsx # Notification state provider
│ ├── upgrade-banner.tsx # Pro upgrade prompt
│ ├── navbar.tsx # Site navigation with auth
│ └── user-menu.tsx # GitHub OAuth sign in/out
├── hooks/
│ ├── use-tilesets.ts # Tileset CRUD + PMTiles preview hooks
│ ├── use-api-key.ts # API key management hook
│ ├── use-user.ts # Current user hook
│ └── use-deactivate.ts # Account deactivation hook
├── lib/
│ ├── api.ts # API client (tilesets, keys, notifications, user)
│ ├── notifications.ts # Notification types and helpers
│ └── plans.ts # Plan definitions and limits
└── public/
├── tileforge.worker.js # Standalone Web Worker (importScripts, no bundler)
└── wasm/ # wasm-pack output (--target no-modules)
| Service | Purpose |
|---|---|
| web | Next.js 16 — UI, auth (Auth.js v5), SSR |
| api | Rust (axum) — modular handlers for tiles, tilesets, auth, rate limiting |
| worker | Rust — async job consumer, thumbnail generation |
| NATS | JetStream job queue — durable publish, explicit ack, retry/DLQ |
| Redis | Progress cache + rate limiting (job queue fallback if no NATS) |
| Postgres | Users, tile sets, notifications, API keys |
| S3 | Tile storage (ZIP, PMTiles, thumbnails, uploads) |
uploads/{job_id}.bin # Temporary upload (deleted after processing)
tiles/{job_id}/tiles.zip # ZIP output
tiles/{job_id}/tiles.pmtiles # PMTiles output
tiles/{job_id}/thumbnail.jpg # 480px JPEG thumbnail
Turbopack cannot bundle WASM imports inside Web Workers. The worker and WASM glue are plain scripts in public/, loaded via importScripts(). The React hook creates the worker with new Worker("/tileforge.worker.js").
- Rust (stable) — rustup.rs
- wasm-pack —
cargo install wasm-pack - Node.js 20+ and npm
- Docker — for Postgres, MinIO, Redis, and NATS
- mprocs —
cargo install mprocs(tabbed TUI for running services) - Stripe CLI —
brew install stripe/stripe-cli/stripe(for webhook testing)
1. Create env files:
.envin the project root with Rust service variables:
DATABASE_URL=postgres://tileforge:tileforge@localhost:5433/tileforge
REDIS_URL=redis://127.0.0.1:6380
NATS_URL=nats://127.0.0.1:4222
JWT_SECRET=<shared-secret>
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=tileforge
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadminweb/.env.localwith Next.js variables:
DATABASE_URL=postgres://tileforge:tileforge@localhost:5433/tileforge
AUTH_SECRET=<random-string>
AUTH_GITHUB_ID=<your-github-oauth-app-id>
AUTH_GITHUB_SECRET=<your-github-oauth-app-secret>
JWT_SECRET=<shared-secret>
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_...2. Start everything:
cargo xtask devThis will:
- Start Docker infrastructure (Postgres, Redis, MinIO, NATS) and wait for readiness
- Install web dependencies if needed
- Launch mprocs with tabbed views for api, worker, web, and stripe
Use the mprocs TUI to switch between service logs. Ctrl+q exits and automatically stops Docker containers.
Important: Rust services are compiled with
--release— image processing is 10-100x slower in debug mode.
Open http://localhost:3000.
git clone https://github.com/thesandybridge/tileforge.git
cd tileforgecargo build --releasecd crates/wasm
wasm-pack build --target no-modules --out-dir ../../web/public/wasm --releasecd web && npm install && npm run devRequires Redis and S3 to be configured. See environment variables below.
| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
No | 8080 |
HTTP listen port |
MAX_UPLOAD_BYTES |
No | 524288000 (500 MB) |
Max image upload size |
REDIS_URL |
No | — | Redis URL (enables progress + rate limiting) |
NATS_URL |
No | — | NATS URL (enables JetStream job queue; falls back to Redis) |
DATABASE_URL |
No | — | Postgres URL (enables tileset CRUD) |
CORS_ORIGIN |
No | * (permissive) |
Allowed CORS origin |
JWT_SECRET |
No | — | Shared HS256 secret for JWT auth |
S3_ENDPOINT |
No | — | S3-compatible endpoint URL |
S3_BUCKET |
No | — | S3 bucket name |
S3_ACCESS_KEY |
No | — | S3 access key |
S3_SECRET_KEY |
No | — | S3 secret key |
S3_REGION |
No | us-east-1 |
S3 region |
ADMIN_SECRET |
No | — | Secret for admin-only endpoints (purge) |
| Variable | Required | Default | Description |
|---|---|---|---|
REDIS_URL |
No | redis://127.0.0.1:6380 |
Redis URL (progress tracking) |
NATS_URL |
No | — | NATS URL (JetStream job queue; falls back to Redis BRPOP) |
DATABASE_URL |
No | — | Postgres (for tileset rows) |
S3_ENDPOINT |
Yes | — | S3-compatible endpoint URL |
S3_BUCKET |
Yes | — | S3 bucket name |
S3_ACCESS_KEY |
Yes | — | S3 access key |
S3_SECRET_KEY |
Yes | — | S3 secret key |
S3_REGION |
No | us-east-1 |
S3 region |
| Variable | Required | Default | Description |
|---|---|---|---|
NEXT_PUBLIC_API_URL |
No | http://localhost:8080 |
Rust API URL (client-side) |
AUTH_SECRET |
No | — | NextAuth session encryption secret |
AUTH_GITHUB_ID |
No | — | GitHub OAuth app client ID |
AUTH_GITHUB_SECRET |
No | — | GitHub OAuth app client secret |
JWT_SECRET |
No | — | Shared HS256 secret for API JWTs |
DATABASE_URL |
No | — | Postgres (for Auth.js user store) |
Interactive API documentation is available via Swagger UI at /swagger-ui when the API is running. The OpenAPI spec is served at /api-docs/openapi.json.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
No | Health check |
| POST | /api/tiles |
Optional | Process image into tiles |
| GET | /api/tiles/{id}/progress |
No | SSE progress stream |
| GET | /api/tiles/{id}/download |
Required | Download tiles as ZIP (presigned redirect) |
| GET | /api/tiles/{id}/download/pmtiles |
Required | Download tiles as PMTiles (presigned redirect) |
| GET | /api/tiles/{id}/thumbnail |
No | Tileset thumbnail (JPEG) |
| GET | /api/tilesets |
Optional | List tilesets (paginated) |
| POST | /api/tilesets |
Required | Create tileset |
| GET | /api/tilesets/{slug} |
Optional | Get tileset details |
| PATCH | /api/tilesets/{slug} |
Required | Update tileset |
| DELETE | /api/tilesets/{slug} |
Required | Delete tileset + S3 cleanup |
| GET | /api/tilesets/{slug}/pmtiles-url |
Optional | Presigned PMTiles URL for range requests |
| GET | /api/user |
Required | Current user info + storage usage |
| POST | /api/user/deactivate |
Required | Deactivate account |
| POST | /api/user/reactivate |
Required | Reactivate within 30-day grace period |
| POST | /api/admin/purge-deactivated |
Admin | Purge accounts deactivated > 30 days |
| GET | /api/notifications |
Required | List notifications |
| POST | /api/notifications |
Required | Create notification |
| POST | /api/notifications/read |
Required | Mark all notifications as read |
| DELETE | /api/notifications |
Required | Clear all notifications |
| POST | /api/keys |
Required | Create API key (Pro only) |
| GET | /api/keys |
Required | Get current API key |
| DELETE | /api/keys |
Required | Revoke API key |
tileforge <IMAGE> [OPTIONS]| Flag | Default | Description |
|---|---|---|
-o, --output <PATH> |
tiles.zip |
Output ZIP file path |
-t, --tile-size <PX> |
256 |
Tile size in pixels |
--min-zoom <N> |
0 |
Minimum zoom level |
--max-zoom <N> |
auto | Maximum zoom level (calculated from image dimensions) |
--projection <TYPE> |
flat |
flat or mercator |
--streaming |
Force streaming mode (lower memory) | |
--naive |
Force naive mode (faster for small images) |
# Basic usage — auto-selects processing strategy
tileforge world_map.png -o tiles.zip
# Mercator projection for an equirectangular world map
tileforge equirect_world.png -o mercator_tiles.zip --projection mercator
# Custom tile size and zoom range
tileforge large_image.jpg -o tiles.zip --tile-size 512 --max-zoom 6
# Force streaming mode for a huge PNG
tileforge huge.png -o tiles.zip --streamingtiles.zip
├── 0/0/0.png # Zoom 0: 1 tile
├── 1/0/0.png # Zoom 1: 4 tiles
├── 1/0/1.png
├── 1/1/0.png
├── 1/1/1.png
├── 2/0/0.png # Zoom 2: 16 tiles
├── ...
Compatible with Leaflet, Mapbox GL JS, OpenLayers, MapLibre, Google Maps, and any library that supports XYZ tile URLs.
- Drop or browse for an image (PNG, JPEG, WebP)
- Configure tile size, max zoom level, and projection
- Process — runs entirely in a Web Worker via WASM
- Preview — interactive Leaflet map rendered from the in-memory tiles
- Download — single ZIP file ready to deploy
The web UI shows real-time progress (zoom level, tile count, percentage) and post-processing stats (duration, tile count, peak memory estimate).
Standard equirectangular mapping. Each zoom level uniformly scales the source image to fit the tile grid. Suitable for:
- Fantasy/game maps
- Floor plans and architectural drawings
- Artwork and illustrations
- Any non-geographic image
Web Mercator (EPSG:3857) Y-axis remapping. Assumes the source image is an equirectangular (plate carree) projection spanning approximately ±85.051° latitude. Tiles are warped so that equal-size tile rows cover equal Mercator-projected latitude bands. Suitable for:
- Real-world geographic maps
- Satellite imagery in equirectangular format
- Any source intended for use with standard web mapping libraries
The X axis is unchanged between projections — both are linear in longitude.
Naive (small images): Decode the full image, resize to each zoom level's canvas size, crop tiles from the canvas. Simple and fast for images under ~256 MB decoded.
Streaming PNG (large PNGs): Decode the PNG row-by-row using the png crate. For each tile row at max zoom, decode only the source rows needed, extract tiles, then release the rows. Lower zoom levels are built by merging tiles (4 → 1) using a pyramid builder. Peak memory is roughly one tile row's worth of source data.
Strip extraction (large non-PNGs): Fully decode the image (unavoidable for JPEG), but never create resized copies. Each tile is individually cropped and resampled from the source. Pyramid builder handles lower zoom levels.
Instead of creating full-canvas images at every zoom level, the pyramid builder merges tiles bottom-up:
- Generate all tiles at max zoom
- Every pair of tile rows triggers a merge: 4 tiles (2x2) are composited into a 2x tile and downscaled to 1 tile at zoom-1
- Merges cascade recursively down to zoom 0
This means lower zoom levels are generated with no additional source image access.
A GitHub Actions workflow (.github/workflows/wasm-build.yml) automatically rebuilds the WASM output whenever files under crates/ change on the main branch. The built artifacts are committed back to web/public/wasm/.
| Component | Technology |
|---|---|
| Core library | Rust (image, png, zip, pmtiles, thiserror) |
| HTTP API | Rust + axum, tower-http |
| Worker | Rust + async-nats, redis, rust-s3, sqlx |
| WASM bridge | wasm-bindgen, js-sys, wasm-pack |
| CLI | Rust + clap |
| Web framework | Next.js 16 (App Router, Turbopack) |
| Auth | Auth.js v5 (GitHub OAuth), HS256 JWT |
| Styling | Tailwind CSS v4 + shadcn/ui |
| Map preview | Leaflet + react-leaflet + pmtiles |
| ZIP (browser) | fflate |
| Database | PostgreSQL + sqlx migrations |
| Queue | NATS JetStream (durable job queue with retry/DLQ; Redis BRPOP fallback) |
| Storage | S3-compatible (Railway bucket / MinIO) |
| CI | GitHub Actions |
Made with ♥ by sandybridge