Ghost Site, an Arkiv experiment in perishable publishing
Most websites rot silently. A domain lapses, a server bill goes unpaid, a database gets migrated and a route quietly stops resolving. The content is still somewhere in a backup nobody can find, and the URL that used to hold it returns a 404 or, worse, somebody else's page.
Ghost Site makes that process explicit. It is a publication whose pages carry an on-chain expiry, a live death clock, and a single social mechanic: any reader can pay to keep a page alive for two more days. When the extensions run out, the page is gone. What is left is a tombstone with a death date and the count of people who cared enough to spend a fraction of a testnet token.
This post covers the concept, the Arkiv primitives it relies on, the relayer pattern that makes it usable by people without wallets, and the parts that were harder than expected.
What Arkiv gives you that a cron job does not
You could fake this with Postgres: a delete_at column and a nightly job. Plenty of apps do something similar for GDPR retention. The problem is that a scheduled delete is a promise made by an operator, not by the data. It can be postponed, forgotten, or reversed without anyone noticing, and the record has no way to prove when it was supposed to disappear.
Arkiv stores expiration as a property of the entity itself. Each record has an expiry block, the database refuses to return it after that block, and the only way to move the deadline is to submit a transaction that pays for it. That has three consequences the fake version cannot offer:
- Survival has a price and a public record. Every extension is a transaction with a hash. The leaderboard is not a marketing metric, it is an index of spending.
- The author cannot rewrite history. Flags are set at creation and immutable afterwards. The docs put it plainly: "no operation changes them afterwards, so an entity is whatever it was created as for life."
- A stranger can extend a record they do not own.
permissionlessExtensiondecouples "who controls this record" from "who can keep it alive." That is the entire social mechanic in one flag.
The third point is what made the idea worth building. If only the owner could extend, Ghost Site would be a subscription with extra steps. Because anyone can extend, the audience becomes the thing that decides what the archive remembers.
The data model
One schema holds everything.
export const GhostPages = defineSchema("ghost-site-v1", { slug: str().indexed(), section: str(), title: str(), dek: str(), author: str(), body: bytes(), slot: u64().indexed(), }); Three SDK constraints shaped this shape:
-
str()is capped at 256 bytes. A page body does not fit, so prose lives inbytes()and is encoded withTextEncoderon the way in. - Deletes are not a thing you do by hand. Expiry handles it.
- Ordering only works on indexed columns, so
slot: u64().indexed()exists to give the index a publication order independent of block time.
Values have to be branded. Passing a plain BigInt for slot throws a SchemaMismatchError, which is a friendly failure but an easy one to hit late at night:
slot: u64(BigInt(input.slot)) // correct slot: BigInt(input.slot) // throws Writing a page
The create call is positional. The two flags live inside the options object and are the whole trick.
await client.createEntity( GhostPages, { slug: "the-premise", section: "PREMISE", title: "A website is a promise to keep paying", dek: "Every URL you have ever bookmarked is a bill somebody stopped paying.", author: "THE EDITORS", body: encodeText(bodyMarkdown), slot: u64(BigInt(slot)), } as any, { expiration: ExpirationTime.fromDays(7), flags: { readonly: true, permissionlessExtension: true }, } as any, ); readonly means the content is frozen. permissionlessExtension means anyone can push the expiry out. Together they create a record that cannot lie about what it says, only about how long it lasts.
Extending a page
Extension is a value transfer, not an edit. The engine refuses to move an expiry backwards, so the target is computed from whichever is later, the current expiry or the current block.
const DAY_BLOCKS = 43_200n; // 2 second blocks const target = BigInt(Math.max(currentExpiresBlock, block) + Number(DAY_BLOCKS * 2n)); await client.extendEntity({ entityKey, expiration: ExpirationTime.atBlock(target), } as any); This is the line that makes the clock in the header real. Rabdi produces a block every two seconds, so every countdown in the UI is derived from block height, not from the visitor's system clock. Two people in different timezones see the same remaining time, and a laptop with a wrong clock cannot make a page look alive.
The relayer
Readers should not need a wallet to be generous. The server holds one key and signs extensions on behalf of anyone who clicks.
┌────────────┐ POST /api/keep {slug} ┌──────────────┐ extendEntity ┌──────────┐ │ browser │ ────────────────────────► │ express │ ───────────────► │ Arkiv │ │ no wallet │ ◄──────────────────────── │ relayer key │ ◄─────────────── │ Rabdi │ └────────────┘ txHash, new expiryBlock └──────────────┘ receipt └──────────┘ The key comes from .env and the app never generates one. If it is missing, the server still boots, serves every read, and returns a structured setupIssue. The UI turns that into a setup panel with the exact commands, instead of a page that looks broken.
The relayer's authority is deliberately narrow: extendEntity and nothing else. It cannot create pages, change text, transfer ownership or delete anything. It is a convenience for readers, not an owner of the archive. The 15 second per-IP limit exists because the relayer is paying, and a fun website should not be an open faucet.
The tombstone problem
Expiry has no event. There is no callback that fires when a record is deleted, because deletion is not an action, it is a state the record is no longer in. Queries simply stop returning it.
That means the graveyard cannot be built from chain reads alone. If a page disappears between two polls, the app has no way to know what it said, how long it lived, or who last saved it.
The fix is a local index that remembers the living:
// data/registry.json { "pages": { "the-premise": { "title": "A website is a promise to keep paying", "dek": "Every URL you have ever bookmarked is a bill somebody stopped paying.", "expiresAtBlock": 818704, "lastSeenBlock": 516304, "lastSeenAt": "2026-11-11T17:31:02.000Z", "entityKey": "0x…" } } } Every read of /api/state writes the live set into that file. Any slug that is in the registry but missing from the chain is, by definition, dead. The relayer computes its death time from the last seen block plus the remaining blocks, which is accurate to within one poll interval.
const blocksLeft = Math.max(0, meta.expiresAtBlock - meta.lastSeenBlock); const diedAtMs = lastSeenAt + blocksLeft * BLOCK_SECONDS * 1000; That is enough to print a tombstone with a birth date, a death date, a lifetime in seconds, the number of saves, and the last saver's address. A separate ledger records every extension with its transaction hash, so "Most Loved" is real spend rather than a counter someone could increment twice.
This is the least pure part of the project and worth being honest about: the chain holds the truth about what is alive, the local index holds the truth about what used to be alive. Lose data/ and you lose the graveyard, not the site.
Building a brand instead of a demo
The first version of the UI looked like every other hackathon project: a dark card grid with a hero gradient. Arkiv's own site is nothing like that. It is light, editorial, and typographically confident, built on a paper tone with an ink blue accent and a lot of horizontal rules. So the interface was rebuilt against that language:
-
#f2f2f7paper,#181ea9signature blue,#141414ink, mono for every number - Space Grotesk and IBM Plex Mono, self hosted, because a font request to a third party is the first thing to fail
- a grain overlay, 1px rules, small caps labels, no rounded card shadows anywhere
- decay rendered as a thin horizontal meter that turns amber, then coral, then dead grey
- a marquee at the top that reads like a newspaper ticker:
ARKIV · RABDI TESTNET · RECORD 516,304 · NEXT DEATH IN 04:12:38:07
The editorial direction is not decoration. It is an argument. A page that looks like a print artefact makes its disappearance feel like losing an object, not losing a URL. A card grid would have made the same data feel like a dashboard.
Two accessibility fixes came out of auditing the built page rather than eyeballing it. A headless Chrome harness measured the rendered DOM and found the coral accent at 2.55:1 on the paper background, which fails WCAG AA at small sizes, plus one button on blue that had inherited an underline colour. Both were caught by numbers, not taste. The coral stayed for decoration and moved to #b23a10 where it carries text.
contrast .hero h1 em #ff6a3d on #f2f2f7 2.55 fail, replaced with #b23a10 at 5.37 contrast .btn #f2f2f7 on #181ea9 10.47 pass overflow none detected at 1440, 820, 390 console 0 errors, 0 failed requests What the numbers look like
- Seven pages seeded, one of them given a deliberately short thirty minute life so the graveyard fills on day one.
- Block time two seconds, so a day is 43,200 blocks and a save buys 86,400.
- One schema, one write path, one extension path, one read query.
- Three local JSON files, all gitignored, all reconstructible except the tombstones.
- 249 kB of JavaScript, 24 kB of CSS, no runtime dependencies shipped to the browser beyond React.
What I would build next
Resurrection is the obvious one: create a new entity from a tombstone with a revivedFrom field pointing at the dead key, so the archive can show a page that died and came back. Extension auctions are more interesting: the highest bid inside a window sets the next expiry, turning survival into a market with a visible price curve. An NFT tombstone is the worst idea that would probably work, because a dead page is a better collectible than a live one.
The part I would keep is the constraint. Ghost Site is fun because it is small, and because everything it does is one of three operations: publish, extend, expire.
Code & more: https://www.dailybuild.xyz/project/311-ghost-site

