Upload API
The Upload plugin exposes an API for both backend-only uploads and direct browser uploads using presigned URLs. You can:
- Upload from the backend (Node.js
Buffer) and either create a new record or update an existing one. - Generate a presigned upload URL on the backend, send it to the frontend, and upload directly from the browser to your storage provider using an HTTP
PUTrequest (raw file as body, plus anyuploadExtraParamsas headers).- After the file is uploaded, it is considered temporary and can be auto-deleted until you commit it.
- You can commit by calling
commitUrlToNewRecordorcommitUrlToUpdateExistingRecordfrom your custom API. - Or (only for custom create/edit components on the same resource) you can commit by writing
filePathinto thepathColumnNamefield usingupdate:recordFieldValue.
Note: for presigned browser uploads the upload is performed via an HTTP
PUTrequest with the raw file as the request body.uploadExtraParams(if returned) must be sent as HTTP headers during that upload.
Uploading from backend (Buffer API)
This API is useful when files are produced entirely on the backend (for example, reports generated by a background job or files received from a webhook), so you don't need to send them through the browser.
⚠️ For large files we do not recommend sending the data to your backend and then uploading again from there. Instead, use the presigned upload API from the frontend (see the next section).
uploadFromBufferToNewRecord
Uploads a file from a Node.js Buffer, automatically creates a record in the resource, and returns both the stored file path and a preview URL.
import { admin } from './admin'; // your AdminForth instance
...
plugins: [
new UploadPlugin({
id: 'my_reports_plugin', // unique identifier for your plugin instance
....
})
]
...
const plugin = admin.getPluginById('my_reports_plugin');
const { path, previewUrl } = await plugin.uploadFromBufferToNewRecord({
filename: 'report.pdf',
contentType: 'application/pdf',
buffer, // Node.js Buffer with file content
adminUser, // current admin user or system user
// or if you are using non-adminforth handlers, e.g. public API
// adminUser: { isExternalUser: true, pk: null, dbUser: {}, username: 'empty' },
recordAttributes: {
title: 'Generated report',
listed: false,
},
});
- Uses the configured storage adapter (S3, local, etc.) to store the file.
- Automatically creates a new record and stores the file path into the column defined by
pathColumnName, together with any extrarecordAttributesyou pass. - Returns an object
{ path, previewUrl }, wherepreviewUrlis the same URL used for previews inside AdminForth.
previewUrlis an absolute URL which you can use in emails / blogs / other places.
uploadFromBufferToExistingRecord
If you already have a record and only want to replace the file referenced in its pathColumnName field, use uploadFromBufferToExistingRecord. It uploads a file from a Node.js Buffer, updates the existing record, and returns the new file path and preview URL.
const plugin = admin.getPluginById('my_reports_plugin');
const { path, previewUrl } = await plugin.uploadFromBufferToExistingRecord({
recordId: existingRecordId, // primary key of the record to update
filename: 'report.pdf',
contentType: 'application/pdf',
buffer, // Node.js Buffer with file content
adminUser, // current admin user or system user
// or if you are using non-adminforth handlers, e.g. public API
// adminUser: { isExternalUser: true, pk: null, dbUser: {}, username: 'empty' },
extra: {}, // optional extra meta for your hooks / audit
});
- Uses the same storage adapter and validation rules as
uploadFromBufferToNewRecord(file extension whitelist,maxFileSize,filePathcallback, etc.). - Does not create a new record – it only updates the existing one identified by
recordId, replacing the value inpathColumnNamewith the new storage path. - If the generated
filePathis the same as the current value in the record, it throws an error to help you avoid CDN/browser caching issues. To force a refresh, make sure yourfilePathcallback produces a different key (for example, include a timestamp or random UUID).
⚠️ The same recommendation about large files applies here: avoid using
uploadFromBufferToExistingRecordfor very large uploads; prefer a presigned upload flow from the frontend instead.
Presigned uploading from the frontend
For files that originate in the browser (drag & drop, file input, custom SPA, etc.), the recommended approach is a direct upload from the frontend to your storage provider using a presigned URL. The flow looks like this:
- Your custom or admin frontend sends a request to your backend.
- The backend calls
plugin.getUploadUrl(...)and returns{ uploadUrl, filePath, uploadExtraParams, pathColumnName }to the frontend. - The frontend uploads the file directly to
uploadUrlusingXMLHttpRequestwith methodPUT(sending the file as the request body and attachinguploadExtraParamsas request headers). This allows tracking upload progress. - After the upload completes, you commit the uploaded file to a record using either:
plugin.commitUrlToUpdateExistingRecord(update existing record), orplugin.commitUrlToNewRecord(create new record), or- by writing
filePathinto the column named bypathColumnNameand letting the plugin’s hooks mark the file as permanent. This should be done only from custom edit/create components on same resource as plugin installed.
By default, files uploaded via getUploadUrl are treated as temporary and candidates for auto-deletion. They become permanent only after you commit them or write them to pathColumnName.
Getting a presigned upload URL (getUploadUrl)
On the backend (Express, same style as other custom APIs):
import type { IAdminUserExpressRequest } from 'adminforth';
import express from 'express';
...
app.post(
`${ADMIN_BASE_URL}/api/uploads/get-url-existing`,
admin.express.authorize(async (req: IAdminUserExpressRequest, res: express.Response) => {
const { recordId, filename, contentType, size } = req.body;
const plugin = admin.getPluginById('my_reports_plugin');
const { uploadUrl, filePath, uploadExtraParams, pathColumnName } = await plugin.getUploadUrl({
recordId, // can be undefined for new records
filename, // e.g. file.name
contentType, // e.g. file.type
size, // optional, will be validated against maxFileSize to drop error earlier
});
res.json({ uploadUrl, filePath, uploadExtraParams, pathColumnName });
}),
);
On the frontend:
const file = input.files[0];
// 1) Ask your backend to generate upload URL
const { uploadUrl, filePath, uploadExtraParams } = await fetch('/api/uploads/get-url-existing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recordId, // omit for new record flow
filename: file.name,
contentType: file.type,
size: file.size,
}),
}).then(r => r.json());
// 2) Direct upload from browser to storage
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
console.log('Upload progress:', `${pct}%`);
}
};
xhr.addEventListener('error', () => reject(new Error('Upload failed: network error')));
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));
xhr.addEventListener('loadend', () => {
const ok = xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300;
if (!ok) {
return reject(new Error(`Upload failed: HTTP ${xhr.status}`));
}
resolve();
});
xhr.open('PUT', uploadUrl, true);
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
if (uploadExtraParams) {
Object.entries(uploadExtraParams).forEach(([key, value]) => {
xhr.setRequestHeader(key, String(value));
});
}
xhr.send(file);
});
Committing to an existing record (commitUrlToUpdateExistingRecord)
From your backend API (called after the browser upload finishes):
import type { IAdminUserExpressRequest } from 'adminforth';
import express from 'express';
...
app.post(
`${ADMIN_BASE_URL}/api/uploads/commit-existing`,
admin.express.authorize(async (req: IAdminUserExpressRequest, res: express.Response) => {
const { recordId, filePath } = req.body;
const plugin = admin.getPluginById('my_reports_plugin');
const adminUser = req.adminUser; // current admin user
// or if you are using non-adminforth handlers, e.g. public API
// adminUser: { isExternalUser: true, pk: null, dbUser: {}, username: 'empty' },
const { path, previewUrl } = await plugin.commitUrlToUpdateExistingRecord({
recordId,
filePath,
adminUser,
extra: req.extra, // optional HTTP context
});
res.json({ path, previewUrl });
}),
);
Or for custom non-authorized APIs (e.g. public API for external users), make sure to pass an adminUser object with isExternalUser: true to indicate that this is not a real admin user from your database:
...
app.post(
`${ADMIN_BASE_URL}/api/uploads/commit-existing-public`,
async (req: express.Request, res: express.Response) => {
const { recordId, filePath } = req.body;
const plugin = admin.getPluginById('my_reports_plugin');
const adminUser = { isExternalUser: true, pk: null, dbUser: {}, username: 'empty' };
// or if you are using non-adminforth handlers, e.g. public API
// adminUser: { isExternalUser: true, pk: null, dbUser: {}, username: 'empty' },
const { path, previewUrl } = await plugin.commitUrlToUpdateExistingRecord({
recordId,
filePath,
adminUser,
extra: {}, // optional extra context
});
res.json({ path, previewUrl });
},
);
This updates only the existing record (no new record is created) and replaces the value in pathColumnName with filePath. If the path is the same as the current value, an error is thrown to avoid caching issues.
Committing and creating a new record (commitUrlToNewRecord)
If you want the upload to create a new record:
import type { IAdminUserExpressRequest } from 'adminforth';
import express from 'express';
...
app.post(
`${ADMIN_BASE_URL}/api/uploads/commit-new`,
admin.express.authorize(async (req: IAdminUserExpressRequest, res: express.Response) => {
const { filePath, recordAttributes } = req.body;
const plugin = admin.getPluginById('my_reports_plugin');
const adminUser = req.adminUser; // current admin user
// or if you are using non-adminforth handlers, e.g. public API
// adminUser: { isExternalUser: true, pk: null, dbUser: {}, username: 'empty' },
const { path, previewUrl, newRecordPk } = await plugin.commitUrlToNewRecord({
filePath,
adminUser,
extra: req.extra,
recordAttributes, // e.g. { title: 'Generated report', listed: false }
});
res.json({ path, previewUrl, newRecordPk });
}),
);
This will:
- Mark the uploaded key as not a deletion candidate.
- Create a new record with
pathColumnNameset tofilePath(plus any extrarecordAttributes). - Return both
previewUrland the new record primary key.
Uploading to one field from another (custom create/edit components)
Sometimes you want to upload a file from one field (custom editor) but store the final path in another field that is handled by an Upload plugin instance. The recommended way is to:
- Call your backend to get a presigned URL via
getUploadUrl. - Upload from the browser directly to storage.
- Emit
update:recordFieldValuefrom the custom component with{ fieldName: pathColumnName, fieldValue: filePath }so the other field is updated.
Note: if the target UploadPlugin column is hidden on the current page (
showIn.create: false/showIn.edit: false), the backend rejects such updates by default. To allow this, set the target column config toallowModifyWhenNotShowInCreate: trueand/orallowModifyWhenNotShowInEdit: true.
This lets you reuse the same Upload plugin instance (and its preview logic) while controlling the UX from a different field.
Example Vue custom editor that uploads an avatar and writes the result into another field handled by Upload plugin:
<template>
<div class="flex flex-col gap-2">
<input type="file" accept="image/*" @change="onFileChange" />
<img v-if="preview" :src="preview" class="w-16 h-16 rounded-full object-cover" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{ record: any }>();
const emit = defineEmits([
'update:value', // optional: current column value
'update:recordFieldValue', // update another field in the record
]);
const preview = ref<string | null>(null);
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// 1) Ask backend for presigned URL
const { uploadUrl, filePath, uploadExtraParams, pathColumnName } = await fetch(
'/api/uploads/avatar/get-url',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recordId: props.record.id, // undefined for create
filename: file.name,
contentType: file.type,
size: file.size,
}),
},
).then(r => r.json());
// 2) Direct upload to storage
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
console.log('Upload progress:', `${pct}%`);
}
};
xhr.addEventListener('error', () => reject(new Error('Upload failed: network error')));
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));
xhr.addEventListener('loadend', () => {
const ok = xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300;
if (!ok) {
return reject(new Error(`Upload failed: HTTP ${xhr.status}`));
}
resolve();
});
xhr.open('PUT', uploadUrl, true);
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
if (uploadExtraParams) {
Object.entries(uploadExtraParams).forEach(([key, value]) => {
xhr.setRequestHeader(key, String(value));
});
}
xhr.send(file);
});
// 3) Tell AdminForth to store filePath in the target field
emit('update:recordFieldValue', {
fieldName: pathColumnName, // target UploadPlugin field
fieldValue: filePath,
});
// optional local preview
preview.value = URL.createObjectURL(file);
}
</script>
On the backend, /api/uploads/avatar/get-url can look like this:
import type { IAdminUserExpressRequest } from 'adminforth';
import express from 'express';
...
app.post(
`${ADMIN_BASE_URL}/api/uploads/avatar/get-url`,
admin.express.authorize(async (req: IAdminUserExpressRequest, res: express.Response) => {
const { recordId, filename, contentType, size } = req.body;
const plugin = admin.getPluginById('my_reports_plugin');
const { uploadUrl, filePath, uploadExtraParams, pathColumnName } = await plugin.getUploadUrl({
recordId, // can be undefined for create pages
filename,
contentType,
size, // optional
});
res.json({ uploadUrl, filePath, uploadExtraParams, pathColumnName });
}),
);
⚠️ Do not call
commitUrlToUpdateExistingRecordorcommitUrlToNewRecordfrom the same resource’sbeforeSave/afterSavehooks, as it would create an infinite loop of updates/creates. These methods are intended to be called from your own API endpoints after the browser upload finishes.