Skip to content

A collection of essential patterns and conventions for building reliable, production-ready applications with Arkiv — covering project attributes, data modeling, security, and error handling.

All entities in Arkiv are public and stored in a shared database. Every project must define a unique project attribute and include it on every entity. This is how you distinguish your app’s data from everyone else’s.

Create a dedicated file that exports this attribute:

lib/arkiv.ts
export const PROJECT_ATTRIBUTE = {
key: "project",
value: "myapp-acme-7x9k", // use a globally unique string
} as const;

Include it in every create/update call and every query:

import { PROJECT_ATTRIBUTE } from "./lib/arkiv"
// Creating — always include PROJECT_ATTRIBUTE
const { entityKey } = await walletClient.createEntity({
payload: jsonToPayload({ title, content }),
contentType: "application/json",
attributes: [PROJECT_ATTRIBUTE, { key: "entityType", value: "post" }],
expiresIn: ExpirationTime.fromDays(30),
})
// Querying — always filter by PROJECT_ATTRIBUTE
const result = await publicClient
.select({ key: true, payload: true })
.where(
eq(PROJECT_ATTRIBUTE.key, PROJECT_ATTRIBUTE.value),
eq("entityType", "post"),
)
.limit(50)
.fetch()

Always use createPublicClient for queries. It prevents accidental writes, doesn’t require a private key, and is safe for frontend use. Reserve createWalletClient for backend services that create, update, or delete entities.

Attributes are your indexes. Without the right ones, you’ll fetch too much data and filter client-side.

// Good: attributes map to your query patterns
attributes: [
{ key: "type", value: "vote" }, // filter by entity type
{ key: "proposalKey", value: proposalId }, // link related entities
{ key: "voter", value: voterAddr }, // filter by user
{ key: "choice", value: "yes" }, // filter by value
{ key: "weight", value: 1 }, // numeric for aggregation
]

Individual creates in a loop are slow and expensive. Use mutateEntities():

// Bad — sequential, slow
for (const item of items) {
await walletClient.createEntity(item)
}
// Also bad — parallel writes from one wallet collide on the transaction nonce
await Promise.all(items.map((item) => walletClient.createEntity(item)))
// Good — single batch operation, single transaction
await walletClient.mutateEntities({
creates: items.map((item) => ({
payload: jsonToPayload(item.data),
contentType: "application/json",
attributes: item.attributes,
expiresIn: ExpirationTime.fromHours(1),
})),
})

If you genuinely need separate concurrent transactions, create the account with viem’s nonceManager — see Concurrent Writes and Nonces.

Broad queries return too much data. Always add multiple filter criteria:

// Bad — returns every note ever
await publicClient.select({ key: true }).where(eq("type", "note")).fetch()
// Good — narrows to what you need
await publicClient
.select({ key: true })
.where(
eq("type", "note"),
gt("created", Date.now() - 86400000),
gt("priority", 3),
)
.fetch()

The same applies to the field selection: select() fetches only the fields you name, so ask for what you’ll actually use. select() with no argument (fetch everything) is convenient while prototyping but wasteful in production.

Match expiresIn to actual data lifetime:

Use CaseDuration
Session dataExpirationTime.fromMinutes(30)
CacheExpirationTime.fromHours(1)
Temp filesExpirationTime.fromHours(24)
Weekly dataExpirationTime.fromDays(7)

Don’t over-allocate — it costs more and pollutes queries with stale data.

// Always load from environment
const privateKey = process.env.PRIVATE_KEY
// Never hardcode
const privateKey = "0x1234..." // DANGEROUS

Check length and content before creating entities:

function createNote(userInput: string) {
if (!userInput || userInput.length > 10000) {
throw new Error("Invalid input")
}
const sanitized = userInput.trim()
return walletClient.createEntity({
payload: stringToPayload(sanitized),
contentType: "text/plain",
attributes: [{ key: "type", value: "note" }],
expiresIn: ExpirationTime.fromHours(12),
})
}

If you’ll filter or sort by a value, store it as a number attribute. String attributes only support equality and glob matching.

// Good: numeric — supports gt(), lt() operators
{ key: 'priority', value: 5 }
// Bad: string — only eq() works
{ key: 'priority', value: '5' }

Numeric attribute values must be integers — a float like 19.99 throws an InvalidAttributeError. Scale non-integers to a fixed precision instead:

// Good: scaled to cents — integer, still supports range queries
{ key: 'priceCents', value: 1999 }
// Throws InvalidAttributeError
{ key: 'price', value: 19.99 }
Section titled “10. Model Related Data with Shared Attributes”

Link entities together using a shared attribute key. This is Arkiv’s version of foreign keys:

// Proposal entity
attributes: [{ key: "type", value: "proposal" }]
// Vote entities reference the proposal
attributes: [
{ key: "type", value: "vote" },
{ key: "proposalKey", value: proposalEntityKey },
]
// Query all votes for a proposal
await publicClient
.select({ key: true, payload: true })
.where(eq("type", "vote"), eq("proposalKey", proposalEntityKey))
.fetch()
  • $owner — The wallet that currently owns the entity. Can change via ownership transfer. Use .ownedBy() to check who can modify/delete.
  • $creator — The wallet that originally created the entity. Immutable. Use .createdBy() for tamper-proof source verification.

When your backend publishes data that a frontend reads, filtering by PROJECT_ATTRIBUTE alone is not enough. A malicious actor can create entities with your project attribute.

Combine project filtering with .createdBy():

lib/arkiv.ts
export const PROJECT_ATTRIBUTE = {
key: "project",
value: "myapp-acme-7x9k",
} as const;
export const CREATOR_WALLET_ADDRESS = "0xYourBackendWalletAddress";
import { PROJECT_ATTRIBUTE, CREATOR_WALLET_ADDRESS } from "./lib/arkiv"
const trustedPosts = await publicClient
.select({ key: true, payload: true })
.where(
eq(PROJECT_ATTRIBUTE.key, PROJECT_ATTRIBUTE.value),
eq("entityType", "post"),
)
.createdBy(CREATOR_WALLET_ADDRESS)
.fetch()

The SDK does not retry on failure. Wrap write operations in try/catch and handle each failure mode:

try {
const { entityKey } = await walletClient.createEntity({ ... })
} catch (error) {
// - User rejected the transaction (MetaMask dismissed)
// - Insufficient funds / gas
// - Network error (RPC unreachable)
// - Entity already expired (for update/extend)
console.error("Transaction failed:", error)
}

entity.toJson() returns any. Validate with a schema library to protect against malformed payloads:

import { z } from "zod"
const PostSchema = z.object({
title: z.string(),
content: z.string(),
author: z.string().optional(),
})
type Post = z.infer<typeof PostSchema>
function parsePost(entity: any): Post {
const raw = entity.toJson()
const result = PostSchema.safeParse(raw)
if (!result.success) {
throw new Error("Entity data does not match expected schema")
}
return result.data
}

15. Model Lists with Relationship Entities

Section titled “15. Model Lists with Relationship Entities”

Arkiv attributes are flat key-value pairs — there is no array type. Don’t try to encode lists into attributes:

// BAD — can't query "all profiles with skill frontend"
attributes: [
{ key: "skills_0", value: "frontend" },
{ key: "skills_1", value: "backend" },
]
// BAD — can't query individual skills
attributes: [
{ key: "skills", value: "frontend, backend, devops" },
]

Instead, create separate relationship entities:

// 1. Create the profile
const { entityKey: profileKey } = await walletClient.createEntity({
payload: jsonToPayload({ name: "Alice", bio: "Full-stack dev" }),
contentType: "application/json",
attributes: [
PROJECT_ATTRIBUTE,
{ key: "entityType", value: "profile" },
{ key: "profileId", value: "alice-123" },
],
expiresIn: ExpirationTime.fromDays(30),
})
// 2. One relationship entity per skill
const skills = ["frontend", "backend", "devops"]
await walletClient.mutateEntities({
creates: skills.map((skill) => ({
payload: jsonToPayload({ profileId: "alice-123", skill }),
contentType: "application/json",
attributes: [
PROJECT_ATTRIBUTE,
{ key: "entityType", value: "profileSkill" },
{ key: "profileId", value: "alice-123" },
{ key: "skill", value: skill },
],
expiresIn: ExpirationTime.fromDays(30),
})),
})
// 3. Query all profiles with "frontend" skill
const frontendDevs = await publicClient
.select({ key: true, payload: true })
.where(
eq(PROJECT_ATTRIBUTE.key, PROJECT_ATTRIBUTE.value),
eq("entityType", "profileSkill"),
eq("skill", "frontend"),
)
.fetch()

16. Remember That updateEntity Is a Full Replace

Section titled “16. Remember That updateEntity Is a Full Replace”

updateEntity is a full replace, not a patch: any attribute you don’t re-send is removed, and the payload, content type, and expiration are overwritten. To change only some fields, read the entity first and re-send the complete state with your changes applied:

// Bad — wipes every attribute except "status", replaces the payload
await walletClient.updateEntity({
entityKey,
payload: jsonToPayload({}),
contentType: "application/json",
attributes: [{ key: "status", value: "done" }],
expiresIn: ExpirationTime.fromDays(7),
})
// Good — read, modify, write back the full entity
const entity = await publicClient.getEntity(entityKey)
await walletClient.updateEntity({
entityKey,
payload: entity.payload,
contentType: entity.contentType,
attributes: [
...entity.attributes.filter((attr) => attr.key !== "status"),
{ key: "status", value: "done" },
],
expiresIn: ExpirationTime.fromDays(7),
})

See Mutating Data for details.