Storage
Local (disk)
The zero-config VitNode storage adapter - writes uploads to public/uploads on the API server and serves them back as static files.
The Local adapter is the one that needs no account, no keys and no extra package:
it writes uploads into public/uploads next to your API and serves them back as
static files. Perfect for development and for a single self-hosted server, and
the wrong choice on serverless.
| Cloud | Self-hosted | Package |
|---|---|---|
| ⚠️ Not durable | ✅ Supported | Ships inside @vitnode/core |
Quick start
import { LocalStorageAdapter } from '@vitnode/core/api/adapters/storage/local'
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
storage: {
adapter: LocalStorageAdapter(),
},
})That is enough to store files. Serving them back is one more step, and it depends on which app runs the API - keep reading.
Set it up
Register the adapter
There is nothing to install - the adapter lives in @vitnode/core, which you
already depend on. Import it and set storage.adapter, exactly as in the
Quick start above.
Uploads then land under the current working directory of the API process:
Serve the stored files
Writing a file is not the same as answering a request for it. The adapter exposes
a static descriptor - mountPath, root and stripPrefix - so the mount is
derived from your publicPath instead of hardcoded in two places.
import { serveStatic } from '@hono/node-server/serve-static'
import { storageStaticHeadersMiddleware } from '@vitnode/core/api/middlewares/storage-static.middleware'
import { mkdirSync } from 'node:fs'
const staticStorage = vitNodeApiConfig.storage?.adapter?.static
if (staticStorage) {
mkdirSync(staticStorage.root, { recursive: true })
app.get(
staticStorage.mountPath,
storageStaticHeadersMiddleware(),
serveStatic({
root: staticStorage.root,
rewriteRequestPath: (path) =>
path.startsWith(staticStorage.stripPrefix)
? path.slice(staticStorage.stripPrefix.length)
: path,
}),
)
}
VitNodeAPI({ app, vitNodeApiConfig })Mount serveStatic before VitNodeAPI on the standalone API: the request
then skips the CORS, CSRF, rate-limiter and global middleware, which a static
image has no use for. mkdirSync is only there so serveStatic does not warn
about a missing root before the first upload.
Local uploads are served from your own origin - the one your session cookie
lives on. Without this middleware, an uploaded SVG or HTML file opened
directly in the browser runs its script as your site, signed in as
whoever clicked the link. The middleware adds Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff, so stored files can still be
shown in an <img> but never execute. It costs nothing - leave it in.
The TanStack Start app is the same code in a different file, because /api/* on
that app is one catch-all route handing the request to the very same Hono
application - so the mount belongs inside it, not in the router. serveStatic
comes from @hono/node-server, which that app does not depend on yet:
bun i @hono/node-serverDuring vite dev the app's public/ directory is served at the site root, so
setting publicPath: "/uploads" looks like it works with no mount at all.
vite build copies public/ into the build output once, so files
uploaded at runtime are written somewhere the production server never reads
from. Keep the default publicPath and mount the static descriptor as
above, or use a cloud adapter - which is the honest answer for anything with
more than one instance.
Point the URLs at the right origin
The adapter builds absolute URLs as {baseUrl}/{publicPath}/{key}. baseUrl
defaults to the configured API origin, so this env var is what decides whether
your file URLs are reachable:
| Variable | Required | What it is for |
|---|---|---|
VITNODE_API_URL | Recommended | The public origin of the API. Used as the default baseUrl, so a wrong value produces URLs that 404. Falls back to the browser's own origin, then http://localhost:3000 |
VITNODE_API_URL=http://localhost:8000Pass baseUrl explicitly if the files are fronted by a different hostname than
the API itself.
Upload something and verify
Open AdminCP → Core → System → Integrations and use Test storage on the Storage card: it uploads an image and tells you whether the round trip worked.
Then check the file is really on disk and really served:
ls public/uploads
curl -I http://localhost:8000/api/uploads/2026/09/admin-storage-test/<uuid>.webpA 200 means the mount is right. A 404 with the file present on disk means the
publicPath and the mount disagree.
Options
Prop
Type
The default publicPath is /api/uploads because the API mounts everything under
/api, and mountPath strips that prefix back off so the route is registered
once, in the right place. Change publicPath and the descriptor follows it - but
keep it under /api: stripPrefix is the whole publicPath, so a publicPath
outside the API's own base path strips nothing and every file answers 404.
Gotchas
Serverless platforms give every instance an ephemeral filesystem. Files written by one instance are invisible to the next and gone after a deploy, which shows up as images that worked this morning. Use S3 or R2 or Supabase Storage there.
The upload path is resolved from process.cwd(), so starting the API from a
different directory points it at a different public/uploads. Old files do
not disappear - the keys on the core_files rows still name them - but the
new directory answers the requests.
Deleting a file from disk by hand leaves its core_files row behind, and the
AdminCP Files table will keep listing it with a broken preview. Delete through
the table (or deleteFile) so the row and the bytes go together.
public/uploads is state. A container that recreates its filesystem on deploy
loses every upload, and no core_files row can bring the bytes back. Mount a
volume, or move to a cloud adapter.