Storage
Storage
Upload images in a VitNode plugin route with c.get("storage"). Validate the file, read its public URL and dimensions, and delete it again, on local disk, S3, R2 or Supabase.
c.get("storage") stores uploaded files through the adapter you configure, records each one in the core_files table and gives you back its public URL. In this guide the site-notes plugin gets an admin endpoint that uploads an image for a note, and another that deletes it.
Before you begin
- A storage adapter. Set
storage.adapterin yourvitnode.api.config.ts: Local disk for development, or S3 / R2 or Supabase in production. Without one, uploads fail with500. - The
site-notesplugin with the Create notes permission, from the beginner path up to Permissions. Any plugin with an admin route works the same way.
Create the upload route
Create src/api/modules/admin/notes/upload-image.route.ts:
import { z } from "@hono/zod-openapi";
import { buildRoute } from "@/api/builders";
export const uploadNoteImageRoute = buildRoute({
adminStaffPermission: { module: "notes", permission: "can_create" },
route: {
method: "post",
path: "/images",
request: {
body: {
required: true,
content: {
"multipart/form-data": {
schema: z.object({
file: z.instanceof(File).openapi({ type: "string", format: "binary" }),
}),
},
},
},
},
responses: {
201: {
content: {
"application/json": {
schema: z.object({
id: z.number(),
url: z.string(),
width: z.number().nullable(),
height: z.number().nullable(),
}),
},
},
description: "The stored image.",
},
},
},
handler: async c => {
const { file } = c.req.valid("form");
const image = await c.get("storage").upload({
file,
folder: "site-notes",
maxBytes: 2 * 1024 * 1024,
allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"],
});
return c.json(
{
id: image.id,
url: image.url,
width: image.dimensions?.width ?? null,
height: image.dimensions?.height ?? null,
},
201,
);
},
});adminStaffPermissionkeeps uploads to administrators with Create notes. An upload route without a guard lets any visitor fill your bucket.c.req.valid("form")reads the file the route's schema validated. A request without a file never reaches the handler.maxBytesandallowedMimeTypesrefuse a file that is too large or of another type with400. VitNode also reads the first bytes of every image, so an HTML page renamedcat.pngis refused too.foldergroups the plugin's uploads in the bucket. See Storage keys.
Create the delete route
Create src/api/modules/admin/notes/delete-image.route.ts. It deletes by the file's id, the number upload() returned:
import { z } from "@hono/zod-openapi";
import { buildRoute } from "@/api/builders";
export const deleteNoteImageRoute = buildRoute({
adminStaffPermission: { module: "notes", permission: "can_create" },
route: {
method: "delete",
path: "/images/{id}",
request: {
params: z.object({ id: z.coerce.number().int().positive() }),
},
responses: {
204: { description: "The image and its file record are gone." },
404: { description: "No file has this id." },
409: { description: "Content still uses the file." },
},
},
handler: async c => {
const { id } = c.req.valid("param");
await c.get("storage").deleteFile(id);
return c.body(null, 204);
},
});Add both routes to the routes of adminNotesModule in src/api/modules/admin/notes/notes.module.ts.
Check the result
Add a temporary component that uploads a picked image with fetcher, shows it, and deletes it again. Create src/pages/check-note-image.tsx:
import { Button } from "@vitnode/core/components/ui/button";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { useState } from "react";
import { manifest } from "@/manifest.gen";
export const CheckNoteImage = () => {
const [image, setImage] = useState<{ id: number; url: string }>();
const [status, setStatus] = useState<number>();
const [isPending, setIsPending] = useState(false);
const upload = async (file: File) => {
const formData = new FormData();
formData.append("file", file);
setIsPending(true);
try {
const response = await fetcher({
plugin: manifest.id,
method: "post",
module: "admin/notes",
path: "/images",
formData,
});
setStatus(response.status);
if (response.status === 201) setImage(await response.json());
} finally {
setIsPending(false);
}
};
const remove = async (id: number) => {
setIsPending(true);
try {
const response = await fetcher({
plugin: manifest.id,
method: "delete",
module: "admin/notes",
path: "/images/{id}",
args: { params: { id } },
});
setStatus(response.status);
} finally {
setIsPending(false);
}
};
return (
<div className="flex flex-col gap-3">
<input
accept="image/*"
aria-label="Image to upload"
disabled={isPending}
onChange={event => {
const file = event.target.files?.[0];
if (file) void upload(file);
}}
type="file"
/>
{status ? <output>{status}</output> : null}
{image ? (
<>
<img alt="Uploaded test" className="max-w-xs rounded-md" src={image.url} />
<Button isLoading={isPending} onClick={() => remove(image.id)} variant="destructive">
Delete it
</Button>
</>
) : null}
</div>
);
};formData replaces args for a multipart route. fetcher sends it as the body and lets the browser set the boundary.
Render <CheckNoteImage /> in src/pages/notes-page.tsx, sign in to the AdminCP in the same browser and open /site-notes/notes:
- Pick a PNG. The status reads
201and the image appears, served from a URL such ashttp://localhost:3000/api/uploads/2026/10/site-notes/cf8eā¦.webp. - Press Delete it. The status reads
204, and reloading the image URL now answers404. - Sign out of the AdminCP and pick the image again. The status reads
403. A PDF answers400.
The upload's width and height are only filled in when image processing is on; otherwise they are null. The .webp extension comes from the same setting. Remove CheckNoteImage when you are done.
What upload() returns
| Field | What it is |
|---|---|
id | The core_files row. Store this in your own tables, and pass it to deleteFile(). |
key | Where the adapter stored the bytes, such as 2026/10/site-notes/cf8eā¦.webp. |
url | The public URL of those bytes, for <img src>. |
name | The file name, changed to .webp when the image was converted. |
mimeType | The stored type, after any conversion. |
size | Bytes stored, after any processing. |
dimensions | { width, height }, or null when image processing did not read the file. |
Save the id, not the URL. The adapter builds the URL from the key, so it changes if you move to another bucket or domain.
Deleting files
| Call | What it removes |
|---|---|
deleteFile(id) | The core_files row and the stored bytes. Use this for anything upload() created. |
delete(key) | Only the bytes in the bucket. The row stays, pointing at nothing. Meant for adapter-level cleanup. |
deleteFile() protects files that are still in use:
- Unused files, such as the one above, are deleted.
- Files referenced by content, such as a Content Engine file field, are refused with
409and{ code: "FILE_IN_USE", content: true }. Remove the file from that content first. - Files pinned only by old content revisions are refused with
409,content: falseand the number ofrevisions.deleteFile(id, { force: true })releases those pins and deletes the file. - A member's avatar or cover that uses the file is cleared.
- An id that does not exist returns
404. PassownerIdto delete only if that user uploaded the file.
VitNode never deletes unused uploads by itself. When your plugin stops needing a file, for example because a note was deleted, call deleteFile().
Storage keys
Every upload is stored under a key built from the date, your folder and a random name:
{year}/{month}/{folder}/{uuid}{extension}
2026/10/site-notes/cf8e90c3-49a4-4e21-abb5-27990e6459a3.webpfoldermay be nested, such assite-notes/covers. Each segment starts with a letter or digit and holds only letters, digits,-and_. Anything else, including.., throws.- The extension follows the stored type, not the uploaded name, so a converted image ends in
.webp. - The adapter turns the key into the public URL. With the local adapter that is
{baseUrl}/api/uploads/{key}.
Image processing
With storage.image set, VitNode re-encodes JPEG, PNG, AVIF, TIFF and WebP uploads with sharp, converts them to WebP and records their dimensions:
storage: {
adapter: LocalStorageAdapter(),
image: {
quality: 85, // 1-100, defaults to 85
webp: true, // false keeps the original format
maxPixels: 40_000_000, // width x height, defaults to 40 million
},
},Without image, files are stored exactly as uploaded and dimensions is null. GIF and SVG are never converted, to keep animation and vectors, and an image wider or taller than 16,383 px keeps its format because WebP cannot hold it.
maxPixels guards against image bombs, tiny files that claim huge dimensions and would unpack into gigabytes of memory. VitNode refuses such a file with 400 before decoding it. The type check from the upload route runs whether or not image is set.
Test your adapter
In the AdminCP, open System ā Integrations (/admin/core/system/integrations), choose Test storage on the Storage card and upload an image. It is stored under the admin-storage-test folder and shown back to you, which proves both the upload and the public URL work. Every uploaded file is listed under System ā Files (/admin/core/system/files).