Documentation

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.adapter in your vitnode.api.config.ts: Local disk for development, or S3 / R2 or Supabase in production. Without one, uploads fail with 500.
  • The site-notes plugin 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:

plugins/site-notes/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,
    );
  },
});
  • adminStaffPermission keeps 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.
  • maxBytes and allowedMimeTypes refuse a file that is too large or of another type with 400. VitNode also reads the first bytes of every image, so an HTML page renamed cat.png is refused too.
  • folder groups 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:

plugins/site-notes/src/api/modules/admin/notes/delete-image.route.ts
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:

plugins/site-notes/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:

  1. Pick a PNG. The status reads 201 and the image appears, served from a URL such as http://localhost:3000/api/uploads/2026/10/site-notes/cf8e….webp.
  2. Press Delete it. The status reads 204, and reloading the image URL now answers 404.
  3. Sign out of the AdminCP and pick the image again. The status reads 403. A PDF answers 400.

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

FieldWhat it is
idThe core_files row. Store this in your own tables, and pass it to deleteFile().
keyWhere the adapter stored the bytes, such as 2026/10/site-notes/cf8e….webp.
urlThe public URL of those bytes, for <img src>.
nameThe file name, changed to .webp when the image was converted.
mimeTypeThe stored type, after any conversion.
sizeBytes 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

CallWhat 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 409 and { 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: false and the number of revisions. 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. Pass ownerId to 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.webp
  • folder may be nested, such as site-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:

vitnode.api.config.ts
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).

Adapters