---
title: File uploads
description: Accept bounded multipart files, validate media and size limits, store content, and test valid and invalid requests.
pagination: false
sidebar:
  icon: upload
---

Accept multipart files by mapping request fields into your function's file schemas. RELKIT maps multipart fields into the target function's schemas,
enforces request and file ceilings, and invokes the function only after validation. Uploads are currently
buffered, so limits protect memory as well as domain policy.

## Model the upload operation

> **Before you begin**
>
> First complete [Storage's upload setup](/guides/storage/upload-product-images), including its
> selected adapter, local resource and provider binding. Multipart HTTP parsing alone does not
> install or configure a bucket.

The commerce example keeps storage behavior in a function and transport parsing in a route:

```ts title="examples/commerce/src/services/assets/functions/upload-assets.function.ts"
/** Validates multipart image files, writes them through the declared assets bucket and returns their uploaded names. */
import { defineFunction } from "@relkit/app/functions";
import { z } from "@relkit/app/schema";
import assets from "../buckets/assets.bucket.js";

const uploadedFile = z.file({
  maxBytes: 1024 * 1024 * 10,
  mediaTypes: ["image/jpeg", "image/png"],
});

const uploadAssets = defineFunction({
  input: z.object({
    label: z.string().min(1),
    primary: uploadedFile,
    attachments: z.array(uploadedFile),
  }),

  output: z.object({
    label: z.string(),
    files: z.array(z.string()),
  }),

  dependencies: { buckets: { assets } },

  handler: async ({ label, primary, attachments }, context) => {
    const files = [primary, ...attachments];

    // This demo uses filenames as keys; uploading the same name replaces the file.
    await Promise.all(
      files.map(async (file) =>
        context.buckets.assets.put(file.name, new Uint8Array(await file.arrayBuffer()), {
          contentType: file.type,
        }),
      ),
    );

    return { label, files: files.map(({ name }) => name) };
  },
});

export default uploadAssets;
```

`z.file` values provide the validated file name, media type, size, and bytes. The function declares bucket
access, writes through the provider-neutral client, and returns ordinary JSON metadata. It does not receive a
multipart parser or raw HTTP request.

```ts title="examples/commerce/src/routes/uploads/route.ts"
/** Admits bounded multipart upload requests to the assets service's validated image handler. */
import { defineRoute } from "@relkit/app/routes";
import assets from "../../services/assets/service.js";

export const POST = defineRoute({
  target: assets.uploadAssets,
  accept: "multipart/form-data",
  maxBodyBytes: 10 * 1024 * 1024,
});
```

The route opts into `multipart/form-data` and sets a route-specific body ceiling. The function schema decides
which fields are single files, repeated files, or text values. No explicit `request` is needed for this
matching object shape; use `http.multipart` and `http.multipartAll` inside `http.input` when names differ.

To add this to your own app, follow [Storage's upload walkthrough](/guides/storage/upload-product-images) for the
bucket, public Assets service, provider configuration, and tests. Copying only the route does not create
its dependencies.

## Send a complete multipart request

1. ### Prepare representative files

    Use two real, small PNG files named `primary.png` and `detail.png` in your terminal's working
    directory. Keep their combined request below 1 MiB for the default commerce configuration.
    These commands assume the commerce app is running locally in test mode on port `4000`, with its
    local bucket provider available. Adjust the port for your own app.

2. ### Upload every required field

    ```sh title="Terminal — relkit-orders"
    curl -i -X POST http://localhost:4000/uploads \
      -F label=receipts \
      -F "primary=@primary.png;type=image/png" \
      -F "attachments=@detail.png;type=image/png"
    ```

    Expect HTTP `200` and JSON listing `primary.png` and `detail.png`. Repeat `attachments` for more files.
    Let curl set the multipart Content-Type boundary; do not supply a bare Content-Type header yourself.

3. ### Prove invalid input is rejected

    ```sh title="Terminal — relkit-orders"
    curl -i -X POST http://localhost:4000/uploads \
      -F label=receipts \
      -F "primary=@primary.png;type=text/plain" \
      -F "attachments=@detail.png;type=image/png"
    ```

    Expect a bounded `422` response for the unsupported declared media type. The storage handler must
    not run. Also test missing required fields and duplicate scalar fields.

## Set every relevant limit

Coordinate `server.maxBodyBytes`, the route's `maxBodyBytes`, and individual `z.file` constraints.
The framework checks Content-Length against the server ceiling before the route runs; the request
mapper then enforces the route-specific ceiling, or the server/default limit when omitted. Raising
only the route limit does not bypass an earlier global rejection.

The commerce server ceiling is 1 MiB, even though this route and each file schema declare 10 MiB.
That is why the commands above use small files. A request ceiling includes multipart framing and all
fields; accepting several maximum-size files requires a correspondingly larger total budget. Test
oversized requests with and without Content-Length. See [Responses](/docs/modules/http/responses) for `413`
versus mapping-validation failures.

For large media, streaming ingestion, resumable transfer, or direct-to-object-store uploads, use a purpose-built
upload flow rather than raising a buffered route to unbounded sizes. A signed upload URL can keep large bytes
off the application server while a later validated request records metadata.

## Security checklist

- Generate storage keys independently from untrusted file names when overwriting matters.
- Treat reported media type as input, not proof of file content; the schema does not sniff file bytes.
- Keep private buckets private and return signed URLs only after authorization.
- Avoid logging file bytes, access tokens, or multipart bodies.
- Test missing fields, repeated fields, zero-byte files, media mismatch, and both size ceilings.

{/* Generated by apps/docs/scripts/generate.ts. Do not edit. */}

**Reference and source**

- [`@relkit/routes` API](/docs/api/routes)
- [`@relkit/schema` API](/docs/api/schema)
- [`@relkit/buckets` API](/docs/api/buckets)

- [`examples/commerce/src/routes/uploads/route.ts`](https://github.com/rel-kit/relkit/blob/main/examples/commerce/src/routes/uploads/route.ts)
