Skip to main content

File uploads

Accepting files: a product photo, a gallery, a large import file — and what changes once the files stop being small.

The reference pages cover each piece; this is the path through them.

Decide which path you need​

SituationWhat to set up
Small images and documents (< a few MB)Nothing beyond an image or file field — they travel base64 inside the request
Large filesChunked uploads (Python / Ruby) with an S3 or tus provider
Several files into a related modelA many=True serializer field, which turns the input into multi-file mode

The threshold is a number in the schema, not a decision in the UI: below it a file is embedded, above it the browser uploads to storage and the request carries only a key.

1. Declare the field​

from habarico.serializers import AdminImageFileSerializer, AdminModelSerializer


class ProductSerializer(AdminModelSerializer):
photo = AdminImageFileSerializer(required=False)

class Meta:
model = Product
fields = ["id", "name", "photo"]

A gallery stored in a related model, written from the product's own form:

class ProductSerializer(AdminModelSerializer):
gallery = AdminImageFileSerializer(many=True, required=False, write_only=True)

class Meta:
model = Product
fields = ["id", "name", "gallery"]

def create(self, validated_data):
images = validated_data.pop("gallery", [])
product = super().create(validated_data)
for position, file_obj in enumerate(images):
ProductImage.objects.create(product=product, file=file_obj, position=position)
return product

The field arrives as type: "object", format: "base64-image", and the frontend renders UploadInput without further configuration.

2. Constrain what may be uploaded​

accept, max_size and max_files in the field's options are read by the frontend's upload input, which refuses a non-matching file before uploading it (max_files applies to multi-file fields only — a single field is always capped at one).

Declare the limit as a backend validator, not as an option. Both backends derive accept and max_size from the field's own validators — Python from MaxFileSizeValidator and FileExtensionValidator (or validate_image_file_extension), Ruby from its active_storage_validations — so one declaration both enforces the limit on save and tells the browser about it beforehand. An explicit options={"max_size": …} overrides the derived value and is then a browser-side hint alone, with nothing behind it. max_files is always that: no backend emits or checks it.

{"name": "photo", "type": "object", "format": "base64-image",
"options": {"accept": [".jpg", ".png"], "max_size": 5242880,
"image": {"resize_width": 1600, "crop_aspect_ratio": 1}}}

The keys are shown as they travel on the wire; in the TypeScript types they read camelCased — options.maxSize, options.image.resizeWidth — as How it works explains.

3. Turn on chunked uploads for large files​

Premium

Chunked uploads are a paid feature on both ends: habarico-enterprise on the backend and @eluvia/habarico-enterprise on the frontend. Base64 uploads need neither.

Install and configure the provider once for the project:

Installation, Configuration, Provider selection — resolved per field, against that field's own storage.

From then on the backend injects options.upload into every file field's schema.

On the frontend, add the paid package and import it once, above the admin:

npm install @eluvia/habarico-enterprise
import '@eluvia/habarico-enterprise';

That import registers the S3-multipart/tus transport into the core's registry, and the rest is automatic. (If you already render EnterpriseSchemaAdmin, importing the package is implied and this line is redundant.)

caution

Client-side resize and crop apply to the base64 path only. A file large enough to be chunked is uploaded untouched, so a project relying on resizeWidth for every image needs the threshold above the largest expected image — or a server-side resize.

caution

S3 has no server-side cleanup of its own. Without the lifecycle rules described in S3 temporary file cleanup (or its Ruby counterpart), abandoned uploads and incomplete multipart parts accumulate invisibly and are billed.

Supplying your own transport​

Which code actually moves the bytes is a registry, not a fixed import. @eluvia/habarico-enterprise registers the S3-multipart/tus transport when imported — but a project that uploads through its own service can register its own instead, and needs no paid package to do so:

import {registerChunkedUploadTransport} from 'habarico';

registerChunkedUploadTransport((file, {transport, getAuthHeaders, onProgress}) => {
const controller = new AbortController();
return {
promise: myUploadService(file, {transport, getAuthHeaders, onProgress, signal: controller.signal})
.then((key) => ({upload_key: key})),
abort: () => controller.abort(),
};
});

Register once, before the admin mounts. The transport receives the file and the resolved options.upload config, and returns a handle whose promise resolves to {upload_key} — the same shape the paid transport produces, so nothing downstream changes. Registering again replaces the previous transport, which is also how you override the paid one.

If a field's schema asks for a chunked upload and no transport is registered — the paid package is missing and you registered nothing yourself — that upload fails with an explanatory error. It is deliberately not a silent fallback: a file past its threshold is exactly the one that must not be inlined into the form submit.

4. Know what the browser sends​

PathValue in the request
Base64{"filename": "photo.jpg", "content": "…"}
Chunked{"upload_key": "…"} — the serializer moves the object into place on save
Multi-fileAn array of either shape

The full sequence is on Frontend contract for Python / Ruby.

Bulk upload as an action​

To attach many images to a record without opening its form, use the UPLOAD_IMAGES action: it opens a bulk upload dialog and creates one record per file in a related resource. Its parameters are on FieldSchema.

See also​