Your app can keep data on Examplary with storage, from any hook. Every call names whose data it is:
| Scope | Shared by | For example |
|---|---|---|
storage.org | Everyone in the workspace the hook runs for | Which courses a workspace linked |
storage.user | The user the hook runs for, in that workspace | A teacher's dismissed notices |
storage.app | Every workspace that has your app | A catalogue, or a list of workspaces to sync |
Each scope has collections of small JSON documents, and blobs for files.
Collections
A collection is a named set of JSON documents, each with a key you choose. Give it a type to get that type back from every method:
import { storage } from "@examplary/apps";
type LinkedCourse = { courseId: string; linkedAt: string };
export const linkedCourses =
storage.org.collection<LinkedCourse>("linked-courses");await linkedCourses.set("exam_123", {
courseId: "abc",
linkedAt: new Date().toISOString(),
});
await linkedCourses.get("exam_123"); // LinkedCourse | undefined
await linkedCourses.update("exam_123", { courseId: "def" }); // merges fields, throws when there's no document
await linkedCourses.delete("exam_123");
const { items, cursor } = await linkedCourses.list({ limit: 50 }); // sorted by key
for await (const { key, value } of linkedCourses.all()) {
// fetches the next page when it needs to
}- Collection names use lowercase letters, digits and dashes, and start with a letter.
- Keys are strings of up to 512 characters. Use
crypto.randomUUID()when there's no natural one. - A document can be up to 100 KB of JSON.
Blobs
Blobs are files of any size, like exports or generated reports. Examplary gives every blob an ID when you store it, and that ID is the only way to reach it again, so keep it, for example in a collection:
import { storage } from "@examplary/apps";
const { id } = await storage.org.blobs.put(pdf, {
contentType: "application/pdf",
});
await reports.set("2026-11", { blobId: id });
const blob = await storage.org.blobs.get(id); // { id, size, contentType, updatedAt, text(), arrayBuffer() } | undefined
await storage.org.blobs.delete(id);A hook can store files of up to 100 MB itself. To hand a file to the browser, or let the browser upload a bigger one, use a signed link from an action:
// A download link, valid for 5 minutes (at most 24 hours)
const url = await storage.org.blobs.url(id, { expiresIn: 300 });
// An upload link without a size limit: the browser PUTs the file to `url`,
// with the same Content-Type, and the file then has this `id`
const upload = await storage.org.blobs.uploadUrl({ contentType: "text/csv" });Where you can use it
Storage works in every hook while it runs, for the workspace and user it runs for. storage.user is only there when a user started the hook, like a dashboard page action; a cron job has no user.
Components can't use storage directly. Read and write it through your page's actions, so your app's code always decides what the current user may see, and storage.app never reaches a browser.
When a workspace removes your app, its storage.org and storage.user data goes with it, and a user who removes their own installation loses their storage.user data. storage.app data stays until you delete the app.
Looking at your data
While you run dev, the App storage panel of the developer toolbar shows your app's documents for your workspace, for you, and for every workspace.
To seed or check the documents every workspace shares, use the CLI in your app's folder. Add --scope org for your own workspace's documents, and --dev to use the copy that dev runs:
npx @examplary/cli storage set projects project-42 '{"title": "Feedback study"}'
npx @examplary/cli storage set projects project-43 --file project.json
npx @examplary/cli storage get projects project-42
npx @examplary/cli storage list projects
npx @examplary/cli storage delete projects project-42