Upload Files
Accept file uploads, store them in S3 or locally, and serve them back.
Upload Files
Accept file uploads from clients, validate them, store them in S3 or locally, and serve them back through your API.
What you will build
- POST /api/upload — accept and store a file - GET /api/files/:filename — serve a stored file
Steps
Install a storage addon
Go to Addons and install Local Storage (for development) or S3 Storage (for production). S3 requires your bucket name, region, access key, and secret key in Settings > Environment Variables.
Create a files table
In the Database screen, create a files table:
| Column | Type | Notes |
|--------|------|-------|
| id | Serial | Primary key |
| filename | Text | Original file name |
| path | Text | Storage path |
| size | Integer | File size in bytes |
| mimetype | Text | e.g., image/png |
| created_at | Timestamp | Default: now() |
Build the upload endpoint
Create POST /api/upload:
1. Upload File — Field Name: file, Destination: 'uploads/', Max Size: 5242880 (5 MB), Allowed Types: ['image/png', 'image/jpeg', 'application/pdf'], Assign To: uploaded
2. Database Insert — Table: files, Data: { filename: vars.uploaded.originalName, path: vars.uploaded.path, size: vars.uploaded.size, mimetype: vars.uploaded.mimetype }, Returning: *, Assign To: fileRecord
3. Respond — Status: 201, Body: { data: vars.fileRecord }
Build the download endpoint
Create GET /api/files/:filename:
1. Find One — Table: files, Where: { filename: params.filename }, Assign To: fileRecord
2. If/Else — Condition: vars.fileRecord == null
- True: Respond — Status: 404, Body: { error: 'File not found' }
3. Download File — Path: vars.fileRecord.path, Filename: vars.fileRecord.filename, Content Type: vars.fileRecord.mimetype
Test the upload
In the Playground, select POST /api/upload. Switch the body type to Form Data, add a field named file with type File, pick a file from your computer, and click Send.
Good to know
When you switch from Local Storage to S3 Storage for production, your upload and download flows stay the same. The storage addon handles the difference.
Signed URL Upload Flow
Some projects avoid sending large files through the project API. In that setup, the frontend asks Spala for a signed storage URL, uploads the file directly to storage, then calls the project API again to finalize metadata.
Typical frontend sequence:
1. POST /api/uploads/sign with filename, content type, and size. 2. Receive uploadUrl, optional required headers, and a project file id. 3. PUT the file bytes directly to uploadUrl. 4. POST /api/uploads/:id/complete so the backend records metadata and returns the project file object.
Example:
const signed = await fetch(`${SPALA_API_BASE_URL}/api/uploads/sign`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${userJwt}`,
},
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
}),
}).then((response) => response.json());
await fetch(signed.uploadUrl, {
method: "PUT",
headers: signed.headers ?? {},
body: file,
});
const fileRecord = await fetch(`${SPALA_API_BASE_URL}/api/uploads/${signed.fileId}/complete`, {
method: "POST",
headers: {
Authorization: `Bearer ${userJwt}`,
},
}).then((response) => response.json());
The exact route names and response fields are generated by the project. If the upload goes directly to S3 or another storage provider, that storage provider also needs its own CORS policy for the frontend origin.
Addons /addons Browse storage addons Build a REST API /guides/build-rest-api Create the API your uploads belong to