---
name: arkiv-app-architecture
description: Decide your Arkiv app topology and signer model BEFORE writing code, then ship it. Arkiv is a queryable database on Ethereum; this skill picks the architecture — server signer vs embedded wallet vs browser burner vs injected MetaMask; pure-Arkiv vs Arkiv-primary-with-cache vs web2-primary-with-audit-lane; zero-backend static apps — and provides the Next.js server-signing route, env hygiene, Vercel deployment checklist, and production-readiness checklist (write queue, health check, structured logs). Activate when a user mentions Arkiv or @arkiv-network/sdk together with app architecture, project structure, Next.js, Vercel, serverless, API route, signer, wallet setup, private key handling, .env, relayer, gasless UX, deployment, production checklist, backend vs frontend, "where does my key go", or scaffolding a new Arkiv app.
license: MIT
metadata:
  arkiv.skill-version: "1.1.0"
  arkiv.sdk-package: "@arkiv-network/sdk"
  arkiv.sdk-range: ">=0.6.5 <0.7.0"
  arkiv.sdk-verified: "0.6.8"
  arkiv.network-fingerprint: "braga:60138453102"
  arkiv.surface: "createPublicClient, createWalletClient, http, custom, privateKeyToAccount, generatePrivateKey, braga, eq, jsonToPayload, ExpirationTime, createEntity, mutateEntities, buildQuery, getChainId, getBalance, toJson"
  arkiv.last-verified-at: "2026-06-11"
  arkiv.evidence: "8 patterns from 124 hackathon submissions across 5 events"
---

# Arkiv App Architecture

Use this skill when starting (or restructuring) an app on Arkiv — the queryable database on Ethereum. It makes the two decisions everything else depends on — **topology** (where does Arkiv sit relative to other storage?) and **signer model** (whose key signs writes, and where does it live?) — then ships the chosen architecture: Next.js integration, env hygiene, deployment, production checklist. Make these two decisions FIRST. Retrofitting a signer model mid-hackathon is the most expensive mistake in the evidence corpus.

## Ground rules (read before any code)

> **Networks rotate. Verify at https://docs.arkiv.network before relying on these. Testnet entities do NOT migrate between networks** (Kaolin sunset stranded working demos; 8 of 124 submissions are now unrunnable on dead testnets).

- Current testnet: **Braga** · chainId `60138453102` · gas token **GLM** (not ETH).
- RPC `https://braga.hoodi.arkiv.network/rpc` · faucet `https://braga.hoodi.arkiv.network/faucet/` · explorer `https://explorer.braga.hoodi.arkiv.network` — all mutable; externalize them behind env vars.
- SDK: `@arkiv-network/sdk` `>=0.6.5 <0.7.0` (this skill verified against **0.6.8** source).
- **Pin Node 22 LTS.** Node 24 hangs entity updates forever — the tx lands on-chain but the promise never resolves (sdk-js issue #14, open).
- The SDK **re-exports all of viem** — import `http`, `custom`, accounts from SDK subpaths; never install viem separately.
- **Two clients, always.** The wallet client has NO Arkiv read actions (`getEntity`/`query`/`buildQuery` only exist on the public client).
- Never `.orderBy()` — the server silently ignores it and it is removed in 0.7.0. Sort in JS after fetching.

## Decide in this order

1. **Topology** → default: **pure Arkiv** (no other database). Add a cache or web2 lane only when a concrete need forces it (below).
2. **Signer model** → default: **server signer behind an API route**. Move along the spectrum only when users must custody their own data.
3. Only then: schema (see `arkiv-data-modeling`), expiration policy (see `arkiv-expiration-design`), queries, UI.

---

### three-hybrid-topologies

Three placements of Arkiv, in order of preference for a new build:

| Topology | What it looks like | Pick when |
|---|---|---|
| **C. Pure Arkiv** (DEFAULT) | All state is entities; no Postgres, no Redis. Reads = attribute queries, retention = expiration dates. | Hackathons, MVPs, anything ≤ a few entity types. Fewest moving parts; the whole app is verifiable from the explorer. |
| **A. Arkiv-primary + web2 cache** | Arkiv is the source of truth; a disposable local mirror (SQLite/Supabase) serves hot reads and full-text search. | Reads get slow or need search/aggregation Arkiv doesn't do. The cache must be rebuildable from queries alone. |
| **B. Web2-primary + Arkiv audit lane** | Existing product keeps its database; Arkiv stores the tamper-proof record lane (decisions, receipts, anchors). | You are adding Arkiv to a live product, not building on it. |

Cross-reference convention for A and B: store the web2 row id inside the Arkiv payload, and store the Arkiv `entityKey` + `txHash` on the web2 row — both directions, always. Credchain (topology A): "Arkiv is our source of truth, Supabase is just a search index." Cortex runs an SQLite read mirror; holomem keeps Postgres ONLY for API-key→wallet auth mapping (auth off-chain, data on-chain).

Seen in 12 of 124 submissions — Credchain, Proof OS, Cortex, holomem, Climate dashboard, NotarIA, MeshOWN, vialibre.

### zero-backend-apps

Topology C taken to its limit: static hosting + walletless public reads + wallet writes = **no server at all**. Arkiv Lantern runs on GitHub Pages; Arkmy is "zero backend — no database, no API routes beyond /api/chat".

```ts
// lib/arkivRead.ts — safe to run IN THE BROWSER: reads need no key, no wallet
import { createPublicClient, http } from "@arkiv-network/sdk"
import { braga } from "@arkiv-network/sdk/chains"
import { eq } from "@arkiv-network/sdk/query"
import type { Entity } from "@arkiv-network/sdk"

const PROJECT = "myapp-v1"
const APP_WALLET = "0xYourAppWalletAddress" as const

export const readClient = createPublicClient({ chain: braga, transport: http() })

export async function listNotes() {
  const result = await readClient.buildQuery()
    .where([eq("project", PROJECT), eq("type", "note")])
    .createdBy(APP_WALLET) // namespace attributes are organization, NOT auth — trust only your own writer
    .withAttributes(true).withPayload(true) // builder defaults are EMPTY; toJson() throws without payload
    .limit(50)
    .fetch()
  const num = (e: Entity, k: string) => Number(e.attributes.find(a => a.key === k)?.value ?? 0)
  // Sort in JS — NEVER .orderBy(), the server silently ignores it (removed in 0.7.0)
  return result.entities
    .sort((a, b) => num(b, "createdAt") - num(a, "createdAt"))
    .map(e => ({ key: e.key, ...e.toJson() }))
}
```

You still need a server for exactly four things: LLM/API keys, webhooks, scheduled jobs, and gasless relaying (paying gas for users). If none apply, ship static.

Seen in 5 of 124 submissions — Arkiv Lantern, Arkmy, Redline.

### signer-spectrum

Four signing models. Decide with this matrix, then commit:

| Model | Who pays gas | Who custodies the key | UX | Default for |
|---|---|---|---|---|
| **1. Server signer** (API route relayer) — DEFAULT | you | you, in server env | zero wallet; web2 feel ("the app is used like a normal health app" — InforMed) | consumer apps, demos, anything judged on UX |
| **2. Embedded wallet** (Privy et al.) | user or sponsored | provider + user | email/social login, no seed phrase | consumer apps where each user must OWN their entities (Gath3r, Clink, Fhedin) |
| **3. Browser burner** (generated key in localStorage) | user (faucet) | the browser tab | zero backend, zero login | zero-backend demos; judge-verifiable sovereignty (Mnemos) |
| **4. Injected wallet** (MetaMask via wagmi/viem) | user | user | popup per write | crypto-native audiences (Eventra, NEXUS, Workiv) |

With model 1, users can still end up owning their data: the server creates (and pays), then transfers with `changeOwnership` — the create-then-handoff pattern; mechanics in `arkiv-write-safety`. Reads need no wallet in ANY model.

```ts
// Model 3 — browser burner (TESTNET ONLY: the key lives in localStorage, treat it as disposable)
import { generatePrivateKey, privateKeyToAccount } from "@arkiv-network/sdk/accounts"
const pk = (localStorage.getItem("burner") ?? generatePrivateKey()) as `0x${string}`
localStorage.setItem("burner", pk)
const account = privateKeyToAccount(pk)

// Model 4 — injected wallet (custom() is the viem transport, re-exported by the SDK)
import { createWalletClient, custom } from "@arkiv-network/sdk"
import { braga } from "@arkiv-network/sdk/chains"
const [addr] = await window.ethereum.request({ method: "eth_requestAccounts" })
const userWallet = createWalletClient({ chain: braga, transport: custom(window.ethereum), account: addr })
```

Seen in 24 of 124 submissions — MyGather, Ocean, InforMed, AeroTrack, Gath3r, Mnemos, NEXUS, FoodCh3in.

### server-signing-route

The default architecture, in full. The key lives in server env ONLY; the browser calls a route; the route validates, signs, and returns `entityKey` + `txHash`. Three files:

```ts
// lib/arkiv.server.ts — server-only module. Never import from a client component.
import "server-only"
import { createPublicClient, createWalletClient, http } from "@arkiv-network/sdk"
import { privateKeyToAccount } from "@arkiv-network/sdk/accounts"
import { braga } from "@arkiv-network/sdk/chains"

const pk = process.env.ARKIV_PRIVATE_KEY // NO NEXT_PUBLIC_ prefix — that compiles the key into public JS
if (!pk || !/^0x[0-9a-fA-F]{64}$/.test(pk)) {
  // Guard the RAW env var. A real submission shipped `if (!`0x${process.env.PRIVATE_KEY}`)` —
  // "0xundefined" is always truthy, so its missing-key check could never fire.
  throw new Error("ARKIV_PRIVATE_KEY missing or malformed (need 0x + 64 hex)")
}

const transport = http(process.env.ARKIV_RPC_URL) // undefined → chain default; env override survives network rotation
export const publicClient = createPublicClient({ chain: braga, transport })
export const walletClient = createWalletClient({ chain: braga, transport, account: privateKeyToAccount(pk as `0x${string}`) })
```

```ts
// lib/writeQueue.ts — ONE wallet = SEQUENTIAL writes. Parallel writes from one key collide on nonces.
let chain: Promise<unknown> = Promise.resolve()
export function enqueueWrite<T>(fn: () => Promise<T>): Promise<T> {
  const run = chain.then(fn, fn)
  chain = run.catch(() => undefined)
  return run
}
```

```ts
// app/api/notes/route.ts — the ONLY place writes happen. Isolating the write path here also
// future-proofs you: the SDK's write mechanics are slated to change (registry contract, sdk-js PR #64).
export const runtime = "nodejs"         // never "edge": the SDK needs Node APIs (brotli compression)
export const dynamic = "force-dynamic"  // teams hit SDK execution at build time without this

import { NextResponse } from "next/server"
import { jsonToPayload, ExpirationTime } from "@arkiv-network/sdk/utils"
import { walletClient } from "@/lib/arkiv.server"
import { enqueueWrite } from "@/lib/writeQueue"

const PROJECT = "myapp-v1" // stamp on EVERY write and query — see arkiv-data-modeling

export async function POST(req: Request) {
  const body = await req.json()
  // AUTHORIZE + VALIDATE here — this route spends YOUR gas. An open write route means
  // anyone who finds the URL writes entities signed and paid for by your wallet.
  const text = String(body.text ?? "").slice(0, 4000)
  if (!text) return NextResponse.json({ error: "text required" }, { status: 400 })

  const { entityKey, txHash } = await enqueueWrite(() =>
    walletClient.createEntity({
      payload: jsonToPayload({ text }),
      attributes: [
        { key: "project", value: PROJECT },
        { key: "type", value: "note" },
        { key: "createdAt", value: Date.now() },
      ],
      contentType: "application/json",
      expiresIn: ExpirationTime.fromDays(30), // seconds under the hood — always use the helpers
    })
  )
  console.log(JSON.stringify({ op: "create", type: "note", entityKey, txHash })) // structured log = audit trail
  return NextResponse.json({ entityKey, txHash })
}
```

If a single request writes 2+ entities, write them with ONE `mutateEntities({ creates })` batch (chunked at ≤1000 ops/tx — one signature, one nonce, all-or-nothing) instead of looping `createEntity` inside the handler: looping collides on the nonce, is slower, and is non-atomic. Mechanics in `arkiv-write-safety`.

Caveat (from a winning submission's own retro): one server key signing for ALL users means on-chain ownership ≠ user ownership. Acceptable for demos; for production add per-user `changeOwnership` handoff or signature-verified routes.

Seen across the 24 signer-spectrum submissions; the route split (server-signed writes + browser public reads) is the standard Vercel-era shape — MyGather, InforMed, AeroTrack, ArkivLog.

### identity-entity-at-login

Get-or-create an identity entity on first login, keyed by a stable `userKey`. Its `entityKey` becomes the user's id, referenced by every other entity they touch.

```ts
import { eq } from "@arkiv-network/sdk/query"
import { jsonToPayload, ExpirationTime } from "@arkiv-network/sdk/utils"
import { publicClient, walletClient } from "@/lib/arkiv.server"
import { enqueueWrite } from "@/lib/writeQueue"

const APP_WALLET = walletClient.account.address

export async function ensureIdentity(userKey: string): Promise<`0x${string}`> {
  // userKey must be a HASH (e.g. sha256 of email+role, like InforMed) — attributes are PUBLIC, never raw PII
  const found = await publicClient.buildQuery()
    .where([eq("project", "myapp-v1"), eq("type", "identity"), eq("userKey", userKey)])
    .createdBy(APP_WALLET)
    .limit(1)
    .fetch() // fresh builder per call — fetch() mutates the builder
  if (found.entities.length > 0) return found.entities[0].key

  const { entityKey } = await enqueueWrite(() =>
    walletClient.createEntity({
      payload: jsonToPayload({ createdAt: Date.now() }),
      attributes: [
        { key: "project", value: "myapp-v1" },
        { key: "type", value: "identity" },
        { key: "userKey", value: userKey },
      ],
      contentType: "application/json",
      expiresIn: ExpirationTime.fromDays(365),
    })
  )
  return entityKey
}
```

Query-before-create is the uniqueness mechanism (Arkiv has no unique constraints). Race window: with the write queue, concurrent logins for the same user serialize, so the second call's query sees the first call's entity only after it confirms — tolerate rare duplicates or re-query after create.

Seen in 3 of 124 submissions — InforMed, Credchain.

### gateway-beacon-signer

When the data source CANNOT hold a wallet (Bluetooth mesh peers, sensors, NFC chips, scrapers), one gateway holds the funded key and signs all writes. Source identity travels as an attribute, never as the signing key. The gateway is the trust bottleneck — say so in your README and validate before signing.

```ts
// gateway worker: env-held key + write queue + verification hook
async function publishReading(raw: unknown, sourceId: string) {
  const reading = verifyReading(raw) // signature check / schema check / dedupe — BEFORE you sign
  if (!reading) return
  await enqueueWrite(() =>
    walletClient.createEntity({
      payload: jsonToPayload(reading),
      attributes: [
        { key: "project", value: "myapp-v1" },
        { key: "type", value: "reading" },
        { key: "sourceId", value: sourceId },   // who produced it — an attribute, not the signer
        { key: "receivedAt", value: Date.now() },
        { key: "transport", value: "ble-mesh" },
      ],
      contentType: "application/json",
      expiresIn: ExpirationTime.fromDays(30),
    })
  )
}
```

Seen in 3 of 124 submissions — OpenArkiv (winner, Best DePIN), GhostMesh, Chiri.

### dual-namespaces-per-trust-domain

One project namespace per trust domain, each with its own signing wallet. Namespace + signer TOGETHER define who is authoritative for which data. OpenClu runs `skill-capture-ai-catalog-v1` (catalog, signed by contributor device wallets) separately from `openclu-portal-v1` (dashboard identity, signed by the portal wallet).

| Trust domain | Namespace attribute | Signer | Entity types |
|---|---|---|---|
| user-generated content | `myapp-catalog-v1` | user/device wallets | listing, review |
| platform records | `myapp-portal-v1` | platform wallet (env) | identity, receipt |

Readers then verify with `.createdBy(<that domain's wallet>)` — the namespace attribute alone is spoofable on a shared testnet.

Seen in 2 of 124 submissions — OpenClu.

### platform-kit-composable-schemas

If you are building a kit/protocol others build ON (social graph, ticketing, shared profiles): publish your entity schema (entity types + attribute names + payload shapes) as part of the product so other apps can read your graph, and namespace app-specific extensions so they don't clobber shared entities. ASide ships a TypeScript platform kit over profiles/follows/feeds; Arbok does per-app namespace extensions on a shared social graph.

Seen in 2 of 124 submissions — ASide, Arbok.

### scaffolder-driven-onboarding

`npx create-arkiv-app` exists (community bounty winner at ETH Argentina — 5 teaching templates: CRUD, expiring proposals, queries, events, tasks). Use it for project layout, but **replace its signer**: as published (1.1.0), its templates read `NEXT_PUBLIC_PRIVATE_KEY` inside client components — the key is compiled into the public JS bundle — and its generated `.env` names don't match what the templates read. Swap in the server-signing route above.

Seen in 2 of 124 submissions — Create Arkiv App (winner, community bounty), TIN.

---

## Env hygiene

```bash
# .env.example — commit THIS file; never commit .env
ARKIV_PRIVATE_KEY=        # 0x + 64 hex. THROWAWAY testnet key only. No NEXT_PUBLIC_ prefix, ever.
ARKIV_RPC_URL=https://braga.hoodi.arkiv.network/rpc   # externalized — networks rotate, verify at docs.arkiv.network
```

- `.gitignore` must exclude `.env` from commit #1 — a real submission has a live private key tracked in git at the repo root.
- No hardcoded fallback keys in source (`?? "0xabc..."` — also seen in a real submission).
- `NEXT_PUBLIC_`/`VITE_` prefixed vars are bundled into client JS. A private key with that prefix is published to every visitor.
- A key that touched a chat, a log, or a URL is burned — rotate it. On testnet that costs nothing; build the habit.
- Keep ALL network constants (chainId, RPC, faucet, explorer) in env/config, never inline — submissions hardcoding them needed multi-file surgery when their testnet died.

## Deployment checklist (Vercel / serverless)

1. Env vars in the Vercel project settings (Production scope) — `ARKIV_PRIVATE_KEY`, `ARKIV_RPC_URL`. Never in code.
2. `"engines": { "node": "22.x" }` in package.json — Node 24 silently hangs entity updates (issue #14).
3. Arkiv routes: `export const runtime = "nodejs"` (never edge) + `export const dynamic = "force-dynamic"`.
4. `vercel.json` `maxDuration` ≥ 30s on write routes — writes wait for tx receipts (~2s blocks, plus retries). Note: `vercel.json` wins over an in-route `maxDuration` if they conflict — a real submission's SSE stream silently died at the lower cap.
5. Long-lived streams (SSE relays, subscriptions) don't fit serverless lifetimes — see `arkiv-realtime` before building one.
6. Smoke-test the deployed health route (below) before the demo, not during it.

## Production-readiness checklist

- [ ] **Batch every set of writes you have at once** (related OR independent) into one `mutateEntities` call, chunked at ≤1000 ops/tx (one signature, one nonce, all-or-nothing). The **write queue** (snippet above) is only for writes that arrive separately over time and can't be batched. See `arkiv-write-safety`.
- [ ] **Retry policy**: the SDK never retries. Make retries idempotent-aware — re-query before re-create.
- [ ] **Structured logs**: every write logs `entityKey` + `txHash`. The explorer link is your audit trail and your demo proof.
- [ ] **Health check** = chainId verify + balance:

```ts
// app/api/health/route.ts
import { NextResponse } from "next/server"
import { braga } from "@arkiv-network/sdk/chains"
import { publicClient, walletClient } from "@/lib/arkiv.server"

export const runtime = "nodejs"
export async function GET() {
  const chainId = await publicClient.getChainId()
  if (chainId !== braga.id) return NextResponse.json({ ok: false, reason: "wrong chain" }, { status: 503 })
  const balance = await publicClient.getBalance({ address: walletClient.account.address })
  // balance is a bigint — JSON.stringify throws on bigints; stringify it yourself
  return NextResponse.json({ ok: balance > 0n, chainId, glmWei: balance.toString() })
}
```

- [ ] **Key rotation plan**: which env var, who rotates it, what breaks while it rotates.
- [ ] **Rate limits**: the Arkiv hub issues API keys (one per wallet) for elevated RPC rate limits — a NEW surface; check docs.arkiv.network for current enforcement before a high-traffic event.
- [ ] **Write-path isolation**: all writes behind one module (`lib/arkiv.server.ts` + the queue). The SDK's write mechanics are scheduled to change (registry contract, PR #64); isolation makes that a one-file migration.
- [ ] **Expiration audit**: every entity type has a deliberate expiration date (a.k.a. TTL) — a real submission's "permanent" group metadata silently vanished after a flat 30-day default. See `arkiv-expiration-design`.

## Anti-patterns / gotchas

- **Gotcha 5 — wallet client has no read actions.** `walletClient.buildQuery` does not exist. Create both clients; reads go through `createPublicClient`.
- **Gotcha 6 — Node 24 hang.** `updateEntity` (and other writes) succeed on-chain but the returned promise never resolves on Node 24. Pin Node 22 LTS in `engines` AND your CI image.
- **Gotcha 13 — don't install viem.** The SDK re-exports it (`viem ^2.38.2`); a second copy causes type/version mismatches. `http`, `custom`, `privateKeyToAccount`, `generatePrivateKey` all come from SDK subpaths.
- **Gotcha 18 — only `braga` exists.** `kaolin` is deprecated/sunset; `mendoza`/`marketplace`/`rosario` imports fail to compile on ≥0.6.7. If you inherit old hackathon code, the chain import is the first thing to fix.
- **Gotcha 19 — the write path will change.** Today writes are brotli-compressed RLP calldata to a magic address; PR #64 (draft) replaces this with a registry contract. Architecture defense: one write module, one queue, nothing else touches `walletClient`.
- **Key in the client bundle.** Any `NEXT_PUBLIC_*` key is public. Two real submissions (including the scaffolder) shipped signing keys in browser JS. Server env or user wallet — nothing in between.
- **Open relayer route.** An unauthenticated write route lets strangers spend your gas and write entities signed as you (seen live in a real submission). Authorize before signing.
- **Build-time SDK execution.** Next.js may execute route modules at build; without `force-dynamic` teams resorted to lazy `require()` hacks. Set it on every Arkiv route.
- **"Always truthy" env guards.** Validate `process.env.X` raw, with a regex, before wrapping it in a template string.

## Compose with

- `arkiv-data-modeling` — design the entity schema (namespace attribute, types, attributes-vs-payload) right after picking the architecture.
- `arkiv-write-safety` — full-replace updates, `mutateEntities` batching, error catalog, pre-flight checks, `changeOwnership` handoff mechanics.
- `arkiv-expiration-design` — per-entity-type expiration policy; expiration as a feature.
- `arkiv-query-patterns` — pagination, query recipes, injection-safe interpolation for everything your routes read.
- `arkiv-realtime` — polling done well; SSE relay topology and its serverless limits.
- `arkiv-privacy-patterns` — entities are PUBLIC; encrypt payloads before they leave your server.
- `arkiv-crosschain` — when value/contracts live on another chain and Arkiv is the queryable state layer next to it.
- `arkiv-agent-state` — if the app is an AI agent, its ephemeral coordination state has its own patterns.
