Assets
Keep images and files in object storage, and point to them from your content.
Images and other files do not belong in Git or in your content files. Graft stores them in any S3-compatible bucket, such as Cloudflare R2, AWS S3, Tigris, or MinIO. Your content stores only a pointer to the file: its key (its path in the bucket) and optional alt text. Your app turns the key into a URL when it renders the page.
This page sets up storage, uploads a file, references it from a page, and shows it in your app.
Set up storage
Add your bucket's details to .env:
S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com
S3_BUCKET=my-site-assets
S3_ACCESS_KEY=…
S3_SECRET_KEY=…
S3_PUBLIC_URL=https://assets.example.com # optional, see belowS3_REGION defaults to auto, which suits R2. On AWS S3, set it to your
bucket's region, such as us-east-1: AWS rejects requests signed for auto.
Configuration lists every variable.
Add an asset field
fields: {
title: field.string(),
image: field.asset({ optional: true }), // → { key: string, alt?: string }
},Upload a file
From your terminal:
graft asset put ./hero.svg pages/home/hero.svg
The second argument is the key. Keys are lowercase paths, and .. is not
allowed. The command prints the frontmatter to paste into your page. The file's
content type is worked out from its name.
Agents upload with the put_asset MCP tool. An agent on your machine passes a
file path. A remote agent sends the file as base64 with a key.
Both refuse a key that already holds a file, with the error ASSET_EXISTS.
The bucket keeps no history, so a replaced file is gone. Pick a new key for a
new version of a file. To replace the file on purpose, pass --overwrite to
graft asset put, or overwrite: true to put_asset.
Reference it from content
---
title: Home
image:
key: pages/home/hero.svg
alt: Branch grafted onto a trunk
---Show it in your app
Turn the key into a URL with @usegraft/assets:
import { createStorage } from "@usegraft/assets";
const storage = createStorage(); // reads the S3_* variables
export async function assetUrl(key: string): Promise<string> {
return storage.url(key);
}const home = await graft.getContent("pages", "home");
const src = home?.data.image ? await assetUrl(home.data.image.key) : undefined;
If you set S3_PUBLIC_URL, url() returns a plain public URL, which browsers
and CDNs can cache. Without it, url() returns a signed link that works for a
short time. Use a public URL for anything on public pages.
Compiling also records which asset keys each document uses, so search and agents can see which pages use a file.
Next steps
- Schema covers the other field types.
- Configuration lists every storage variable.
- MCP tools covers
put_assetin full.