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 { mkdirSync } from 'node:fs'
const staticStorage = vitNodeApiConfig.storage?.adapter?.static
if (staticStorage) {
mkdirSync(staticStorage.root, { recursive: true })
app.get(
staticStorage.mountPath,
serveStatic({
root: staticStorage.root,
rewriteRequestPath: (path) =>
path.startsWith(staticStorage.stripPrefix)
? path.slice(staticStorage.stripPrefix.length)
: path,
}),
)
}
VitNodeAPI({ app, vitNodeApiConfig })import { serveStatic } from '@hono/node-server/serve-static'
import { mkdirSync } from 'node:fs'
const createVitNodeApi = () => {
const app = new OpenAPIHono().basePath('/api')
const staticStorage = vitNodeApiConfig.storage?.adapter?.static
if (staticStorage) {
mkdirSync(staticStorage.root, { recursive: true })
app.get(
staticStorage.mountPath,
serveStatic({
root: staticStorage.root,
rewriteRequestPath: (path) =>
path.startsWith(staticStorage.stripPrefix)
? path.slice(staticStorage.stripPrefix.length)
: path,
}),
)
}
VitNodeAPI({ app, vitNodeApiConfig })
return app
}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.
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-serverpnpm i @hono/node-servernpm i @hono/node-serverVite's public/ is a build-time snapshot
During 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 |
|---|---|---|
NEXT_PUBLIC_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 |
NEXT_PUBLIC_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/month_9_2026/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
Not durable on serverless
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.
Relative to the process, not to the file
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.
A missing file is a 404, not a repaired row
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.
Back it up like a database
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.
Next
Storage
Upload, serve, and delete files in VitNode with pluggable storage adapters - local disk, AWS S3, Cloudflare R2, or Supabase Storage.
AWS S3 / Cloudflare R2
Store VitNode uploads in an S3-compatible bucket with @vitnode/s3 - one adapter for AWS S3, Cloudflare R2 and anything that speaks the S3 API.