Next.js explorer UI for Lumina, an open-source event indexer and GraphQL data layer for the Stellar network.
Part of the Lumina project, split across three repos:
- lumina-frontend — this repo
- lumina-backend — indexer + GraphQL API + PostgreSQL schema
- lumina-contracts — Soroban Registry contract
npm install
npm run devRuns against http://localhost:4000/graphql by default — start lumina-backend's GraphQL server first (or point at a deployed one, see below).
| Variable | Used by | Default |
|---|---|---|
GRAPHQL_URL |
Server Components (runtime, not exposed to the browser) | http://localhost:4000/graphql |
NEXT_PUBLIC_GRAPHQL_URL |
Client-side queries (LiveFeed seed + polling fallback, GraphQL Playground) — build-time inlined |
http://localhost:4000/graphql |
NEXT_PUBLIC_GRAPHQL_WS_URL |
GraphQL subscriptions (LiveFeed, account Live Activity) — build-time inlined |
NEXT_PUBLIC_GRAPHQL_URL with http→ws / https→wss |
NEXT_PUBLIC_REGISTRY_CONTRACT_ID |
Registry + Stats pages, reading the Lumina Registry directly via Soroban RPC | the deployed testnet registry (CAYUDQPV3RKPM3EXDFGI3457FV677JLUCJ4OLKWGCUBPRIHYKXK3WFAZ) |
NEXT_PUBLIC_SOROBAN_RPC_URL |
Same | https://soroban-testnet.stellar.org |
NEXT_PUBLIC_NETWORK_PASSPHRASE |
Same, and the Registry page's wallet-signed transactions | testnet passphrase |
NEXT_PUBLIC_REGISTRY_READ_ACCOUNT |
Any funded account used only for read-only simulation (no secret key needed) | a funded testnet account |
NEXT_PUBLIC_ERROR_REPORTING_URL |
Optional browser error ingestion endpoint — build-time inlined | unset (reporting off) |
ERROR_REPORTING_URL |
Optional source-map upload endpoint used after a production build | unset (maps not generated or uploaded) |
ERROR_REPORTING_TOKEN |
Optional bearer token for source-map uploads; never exposed to the browser | unset |
| NEXT_PUBLIC_SITE_URL | Canonical URLs, og:image and the sitemap — build-time inlined | http://localhost:3000 |
NEXT_PUBLIC_VAPID_PUBLIC_KEY enables the optional push notifications on /watch. It is unset by default and push stays off unless someone opts in — see docs/PUSH_NOTIFICATIONS.md for the backend endpoint the delivery path still needs.
NEXT_PUBLIC_* values must be set as Docker build ARGs (see Dockerfile), not runtime env vars — Next.js inlines them into the client bundle at build time, so setting them only at container-run time has no effect.
NEXT_PUBLIC_SITE_URL is the one that has to be right in production. Every canonical link, share-card URL and sitemap entry is resolved against it at build time, so a site served from another origin while this still says localhost emits absolute URLs that point nowhere.
lib/routes.ts is the single inventory of the site's routes — path, nav label,
description and crawl policy — and everything else reads from it: the navbar,
sitemap.ts, robots.txt and the per-route metadata. Adding a route means
adding one entry there, not editing four files and hoping they agree.
Indexed. The eight stable routes (/, /explorer, /transactions,
/search, /events, /graphql, /registry, /stats) are in sitemap.xml
and each carries its own title, description, canonical URL and 1200×630 share
card.
Crawl-blocked, still linkable.
| Path | Why |
|---|---|
/accounts/* |
One page per Stellar address, unbounded and unguessable, so there is no finite set for a crawler to walk. A link shared into a chat still previews correctly, because /accounts/[address] has its own generateMetadata and its own generated image. |
/transactions/* |
One page per transaction hash, unbounded for the same reason. A shared link still previews correctly from the hash in the URL alone. |
/*? |
Filter and search views — ?status=failed, ?contractId=…, ?q=…. They are the same page as the route they filter, not separate pages, and each one declares the bare route as its canonical. |
app/robots.ts carries the reasoning inline, as a reason on each entry in
CRAWL_EXCLUDED, so the next person to touch the policy can see why it is what
it is instead of re-deriving it. Account pages are blocked in robots.txt but
not noindexed: blocking a page in robots prevents a crawler from reading
the noindex in the first place, and these pages are meant to be reachable from
external links — a noindex would drop them from search results for exactly the
people who linked them.
Share images are generated rather than stored: app/opengraph-image.tsx draws
the site card, and app/accounts/[address]/opengraph-image.tsx draws one per
account from the address in the URL alone. The account card deliberately never
waits on GraphQL — a preview bot does not retry, so a card that depends on the
indexer being up is a card that is missing exactly when it is most often shared.
LiveFeed on the home page and the Live Activity section on an account page
stream over GraphQL subscriptions rather than polling. lib/subscriptions.ts
speaks the graphql-transport-ws protocol directly over a WebSocket — the
same wire format graphql-ws servers implement, so it interoperates with the
backend's Subscription type without adding a client-bundle dependency.
One socket is shared by the whole tab: two live panels on one page do not mean two connections and two independent opinions about whether the server is up.
Connection state is visible, not silent. The badge reads LIVE, CONNECTING,
RECONNECTING, or POLLING, so a feed that has stopped moving because the
server went away never keeps claiming to be live. Drops reconnect with
exponential backoff and full jitter — the jitter matters more than the growth,
since without it every open tab reconnects in lockstep the moment the server
returns — and active subscriptions are replayed on the new socket, because the
server has no memory of the previous one.
After the retry budget is exhausted, or where WebSocket is unavailable at all
(a restrictive proxy, a server-rendered pass), the feed falls back to the
original 30-second polling rather than going dead.
/watch is where several accounts are followed at once, and it is the reason the
shared socket above is shared. A watch list of eight accounts means one
WebSocket carrying eight multiplexed subscriptions, not eight connections — so
the connection count does not grow with the list, and the server has one view of
whether the tab is connected rather than eight disagreeing ones. Adding a watch
sends one more subscription frame on the open socket; it does not reconnect.
Each watch carries its own filter (lib/filterModel.ts), defaulting to payments
so a quiet watch stays quiet. Filters are part of the watch, not the page, which
is what lets one watch alert on large payments while another takes everything.
New activity on a watch surfaces in two places. The ActivityBell in the navbar
is the always-available one: it survives navigation and reloads, marks the count
read when opened, and links through to the account. A system notification is
raised only when the tab is actually in the background, and only after someone
opts in by clicking — permission is never requested on page load. See
docs/PUSH_NOTIFICATIONS.md for the VAPID key and
the backend endpoint that delivery still requires.
/watch is deliberately excluded from the sitemap (WATCHES.indexable = false)
so a per-person list never ends up indexed, while staying linked from the navbar
for people who want it.
/registry lets a user register a contract on the Lumina Registry directly from the browser, via @creit.tech/stellar-wallets-kit (Freighter, xBull, Albedo, Rabet, and Lobstr are wired in — not locked to a single wallet). Connecting opens a wallet-picker modal; registering builds a register_contract invocation with @stellar/stellar-sdk, simulates it, has the chosen wallet sign it, and submits it via Soroban RPC. The "Recently Registered" list reads the live registry the same way — no wallet needed for that part.
app/ App Router pages
components/ React components
lib/ GraphQL client + formatters
See docs/ARCHITECTURE.md for the server/client component split, the data-fetching patterns and the wallet/transaction pipeline.
MIT
See CONTRIBUTING.md for setup, where new code goes, the staged-file checks, and what a pull request is expected to contain.