serve
Serve the generated static site locally, optionally building it first.
./ritual serve [options]By default, serve serves a previously built dist/ directory (or the directory given by --out-dir). A directory with no index.html is refused rather than answered with bare 404s — see Exit Codes — except under --api, which builds the missing site itself. Pass --build to build the site first and then serve the result — the one-shot preview that used to require running build-site and serve separately. Pass --api to additionally host a live, read-only data API alongside the site — see Hosting with a live backend.
Options
Section titled “Options”| Option | Description | Default |
|---|---|---|
-p, --port <number> | Port to serve on. Validated at parse time (1–65535); an invalid value exits with code 2. | 3000 |
--host <address> | Host address to bind to. 0.0.0.0 binds all interfaces. | 0.0.0.0 |
--build | Build the site before serving it | |
--api | Serve a live read-only data API alongside the site (see below) | |
--out-dir <path> | Serve this directory instead of dist/ (with --build, build into it and serve it) | dist |
--sell-mode | Offer sell mode for this run. Valid with --api even without --build; a plain serve rejects it (see below). |
Live API mode (--api)
Section titled “Live API mode (--api)”With --api, the server hosts the same static dist/ plus a live, unauthenticated, read-only API on the same port:
| Method | Path | Serves |
|---|---|---|
| GET | /index.json, /decks/…, /collections/…, /wanted/… | Live list data, computed from the markdown files on request (shadowing the baked copies) — edits made via the CLI or admin appear without rebuilding. |
| GET | /locales/{tag}.json | One published locale dictionary from the built site, served as a plain static file. |
| GET | /art/{path} | One custom art image, read live from the configured art directory at the same path a built site carries it (see below). |
| GET | /api/autocomplete | Card-name autocomplete over the card cache with the same term matching as the admin editor (in tre finds “In the Trenches”). |
| GET | /api/card-printings, /api/card-price | Cached printings and staleness-gated price lookups (the same endpoints the admin editor uses). |
| GET | /api/cards?ids=… | Cache-only card lookup by Scryfall ID (max 200 per request), used to restore a shared trade link without Scryfall. |
| POST | /api/card-prices | Batch price refresh by card name (used by the site’s Update Prices button in hosted mode). |
| GET | /api/buylist/status | Which buyers this server can quote against and how fresh its cached buylist is. 404 unless sell mode is on or priceSources includes cardkingdom. |
| POST | /api/buylist/quotes | Card Kingdom’s current offer for specific printings (max 500 per request). Strictly cache-backed — there is deliberately no public refresh route. |
The two buylist routes are not how the public site prices a list: it gets its buy prices baked into the live list payloads (below). The quotes route is used for one thing the payloads cannot cover — printings the build never saw, which the add-card dialog’s search turns up and the printing pickers price under the Card Kingdom store. They are also mounted for other clients (a script, a second front end), and answer 404 when neither sell mode nor the cardkingdom price store is on.
The web app detects the backend through the served index.json and switches its behavior: list pages refetch live data on navigation, the editor’s add-card search uses the cache-backed term matching (the “results may differ” Scryfall note disappears), and price refreshes go through the server. A small Live badge appears in the site header.
Details:
- Sell mode quotes are baked into the live payloads, from a cache refreshed at startup. Sell mode is off unless
site.sellModeis on or the run passed--sell-mode. When it is on, each served list payload carries that list’s Card Kingdom buy prices, computed from the cached feed exactly the waybuild-sitebakes them — so pricing a list never calls the quotes API, and a served list picks up a newly refreshed feed on its next request without a rebuild. WhenpriceSourcesincludescardkingdom, the live payloads also carry Card Kingdom’s own printing picks for name-only lines and a quote for every printing each list carries, at every finish, so the card modal’s other-printings grid and the printing pickers can price them; only printings outside those lists entirely (an add-card search result) reachPOST /api/buylist/quotes. The feed itself is never downloaded per request (an unauthenticated wildcard-CORS endpoint must not be able to pull ~70 MB): startup is where that happens, where an already-downloaded feed more than a day old (Card Kingdom regenerates it daily) is redownloaded before the server binds — under the same--refreshpolicy, sono-bulk/neverskip it, and a failed download leaves the older feed in place with a warning rather than failing the start. Startup only ever updates a buylist: a workspace that has never downloaded one is left alone (no prompt, no ~70 MB on a capability this deployment may not use), the buylist routes answer503with the remedy, and sell mode shows the “prices unavailable” notice until you download one withritual sell --refresh autoor the admin site. - Read-only, no auth. Only the routes above exist; an unmatched
/api/*path answers a JSON 404 (never the SPA fallback), and none of the admin server’s mutation or auth surface is reachable. Public edits stay client-side (export/import change bundles), exactly as on the static site. One local exception: like every list-writing command, startup runs the card-ID backfill, persisting any missing&Ncard IDs into the list files on first run (plainserve, without--buildor--api, never does). - Builds when there is nothing to serve. With
--apithe data is served live, so an unbuilt served directory (dist/, or--out-dir) is a missing app shell rather than missing content: the command builds the site itself and then serves it, instead of refusing. (Plainservestill refuses — there the build is the content.) An existing build is served as-is; pass--buildto rebuild it, which is also the only way to give the build any of its flags. - Cache warming. Live payloads are computed from the card cache with no Scryfall fallback, so startup applies the same freshness gates
build-siteapplies, over every card the served lists reference — entries, deck primers, and change history — under the same--refreshpolicy (which is therefore valid with--apieven without--build): a bulk download when the cache has never been downloaded, is more than a week old, or is missing many of those cards; then the offer to redownload day-old prices; then, when a sample of the site’s cards carries no oracle/art tags, the offer to download them (skipped entirely on an empty cache). The one gate a build has that this does not is the per-card refetch of missing or stale cards — a live server never fetches from Scryfall — so--refresh no-bulkwarms nothing here, exactly like--refresh never, which is what a cache server deployment wants. Each gate is best-effort: a declined prompt or a cold network leaves the cache as it was and the server still starts. When--apibuilds (or--buildwas given), the build applied these gates already and startup does not ask again. - Cache backend. The server reads the card cache through the standard selection: the local
cache/cache.jsonby default, or a cache server when--cache-server/RITUAL_CACHE_SERVERis set. A bulk refresh run by a separate CLI process is picked up automatically (the server watches the cache file). Never expose the cache server itself to browsers — it has unauthenticated write routes; only theserve --apiprocess should talk to it. - CORS. API and JSON routes answer with
Access-Control-Allow-Origin: *, so a statically deployed site (CDN/GitHub Pages) can point at a separately hosted instance viasite.apiBaseUrl— see Hosting with a live backend. - Freshness. Live JSON is served with
Cache-Control: no-cache, content ETags, andLast-Modified, so unchanged payloads revalidate as cheap 304s. Every response also carriesX-Content-Type-Options: nosniffandReferrer-Policy: strict-origin-when-cross-origin. - Images. Live data always uses Scryfall image URLs;
--cache-imagesonly affects the statically built assets. - Custom art. Nothing is copied in live mode:
/art/{path}reads the workspace’sartDiron every request, at the exact path the baked value names, so a new image or an edited.art.jsonshows up without a rebuild (the sidecar’s modification time is part of each list’s freshness stamp, so the list payload re-renders too). Only.avif,.gif,.jpeg,.jpg,.png, and.webpare served — SVG deliberately is not — and any other extension, or any path leaving the art directory, is a JSON404. Plainservehas no such route; it serves whateverbuild-sitecopied intodist/art/. - Interface language. The live
index.jsonreports the currentuiLocale, so aritual config set uiLocaleis picked up on the next index request with no restart and no rebuild — the same waydefaultCurrencyandsearchDebounceMsare. Which dictionaries exist is still a build-time decision (--locales), and the dictionaries themselves are ordinary static files: noAccept-Languagenegotiation happens anywhere in this server, so a CDN-hosted site and a served one stay byte-identical deployments.
Build options (require --build)
Section titled “Build options (require --build)”With --build, serve accepts the full build-site option surface. Passing any of these without --build is a usage error: the command exits with code 2 and an error naming the offending flag(s). Four exceptions: --refresh, which is also meaningful with --api (cache warming); --sell-mode, likewise, since the live server reads sell mode per request; --out-dir, which names the directory to serve whether or not a build runs; and --locale, which is a global flag every command accepts. The first two are exempt only under --api.
| Option | Description |
|---|---|
-v, --verbose | Show list of cards being fetched from Scryfall |
--cache-images | Download and use local deck card images in dist/images instead of URLs |
--decks [names...] | Deck names or URLs to include in the site (default: the site.includeDecks config selection) |
--collections [names...] | Collection names to include in the site (default: the site.includeCollections config selection) |
--wanted-lists [names...] | Wanted list names to include in the site (default: the site.includeWantedLists config selection) |
--currencies <list> | Comma-separated currencies to include on the site: usd, eur, tix (default: all three) |
--refresh <mode> | Card cache refresh policy: ask (default — bulk-downloads an empty or stale cache without asking, prompts for the price and tag refreshes), auto, no-bulk, or never (see build-site). |
--theme <name> | Initial theme served to first-time visitors (built-in name or a custom name from --theme-file). Defaults to default. |
--theme-file <path...> | Load one or more custom theme JSON files; each is added to the runtime theme list under its declared name. |
--locale <tag> | UI locale baked into the built site — the <html lang>/dir and the language it opens in (default: the uiLocale config value). Ritual’s own text, not the card language. Also a global flag, so unlike the rest of this table it is accepted without --build, where it only changes this command’s own output — see the note below. See Localized builds. |
--locales <tags...> | Which locale dictionaries to publish into locales/, which is what the in-app language switcher offers (default: en; all publishes every one this build has). |
--locale-file <path...> | Load one or more locale dictionary JSON files, each named for its tag (de-AT.json); their locales become selectable alongside the built-in ones. |
--moxfield-user-agent <agent> | User agent for fetching Moxfield deck URLs (see build-site). |
--out-dir <path> | Build into this directory instead of dist/, and serve it. A relative path resolves against the Ritual directory; a build replaces its output directory wholesale, so the Ritual directory itself (or any ancestor of it) is refused with exit code 2 — including without --build, since the flag names one directory for both roles. See build-site. Valid without --build, where it simply names the built directory to serve. |
--sell-mode | Offer sell mode for this run even when site.sellMode is off: update the Card Kingdom buylist (~70 MB) and serve its buy prices. Also valid with --api and no --build — the live payloads then carry the baked quotes and the buylist routes answer instead of 404ing. Enable-only. See build-site → Sell mode. |
Plain serve — no --build, no --api — is a static file server: nothing in that process ever consults sell mode, so what the served directory shows is whatever the build that produced it baked. --sell-mode there would be an inert no-op, and is a usage error (exit code 2) for the same reason every other build-only flag is. Add --build to bake the quotes, or --api to serve them live.
Examples
Section titled “Examples”Serve a previously built site on the default port (3000):
./ritual serveServe a preview directory built earlier, without rebuilding it:
./ritual build-site --out-dir preview./ritual serve --out-dir previewServe on a custom port:
./ritual serve --port 8080Build everything and serve at http://localhost:3000:
./ritual serve --buildBuild only specific decks, then serve:
./ritual serve --build --decks "Atraxa Superfriends" "Mono Red Aggro"Host the site with the live backend, building it first if it has not been built yet:
./ritual serve --apiRebuild the static site and then host it:
./ritual serve --build --apiHost with a shared cache server providing the card data:
./ritual serve --api --refresh never --cache-server cache-host:4000Exit Codes
Section titled “Exit Codes”| Code | Meaning |
|---|---|
0 | The server ran (it serves until stopped with Ctrl+C). |
1 | The build failed at runtime (e.g. an unreadable --theme-file, or a build error), or there is no built site to serve and none was built (no index.html in the served directory; under --api this means the build it ran failed). The server is not started. |
2 | Usage error: invalid --port, a build-only flag without --build, or an invalid --currencies/--theme value. |
- Files are served from the
dist/directory, or from--out-dirwhen given — the build and the server always agree on one directory. Without--buildor--api, runbuild-sitefirst to generate the content; serving a directory with noindex.htmlis refused with exit code 1 and a message naming both remedies. - With
--build, the site is built exactly asbuild-sitewould; if the build fails, the server does not start. --hostdefaults to0.0.0.0(all interfaces), matchingadmin. The printed URL names the address the server actually bound: a wildcard or loopback bind printshttp://localhost:<port>(use the machine’s address to reach it from another device), and an explicit--host 192.168.1.5prints that address.- Press
Ctrl+Cto stop the server. - For an auto-restarting workflow that rebuilds when source or data files change, see Development → Dev Workflow.
bun run dev serveappends--buildautomatically and requires an explicit--refreshmode (auto,no-bulk, ornever) so the cache refresh prompt can be answered non-interactively.