# Direct-to-storage uploads (/docs/infrastructure/storage/direct-uploads)

---
title: Direct-to-storage uploads
description: Mint a presigned upload in a route, hand it to the browser, and let the bytes travel straight to S3 or R2 while your server handles only the metadata.
kind: guide
package: questpie
---

The ordinary path is server-mediated. The browser posts to
`POST {basePath}/:collection/upload`, QUESTPIE validates and streams the bytes
into the backend, then writes the row. Every byte crosses your server. For a
video or a large archive that is the cost you want to remove.

## Mint the upload

There is no built-in presign route, because the key and the size cap are
decisions only your application can make. Write a [route](/docs/code/routes)
that calls `signedUploadUrl` on the storage handle.

```ts title="src/questpie/server/routes/sign-upload.post.ts"
import { route } from "questpie/services";
import { z } from "zod";

export default route()
	.post()
	.schema(z.object({ contentType: z.string() }))
	.access(({ session }) => !!session?.user) // routes are public by default
	.handler(async ({ input, storage }) => {
		const key = crypto.randomUUID(); // never sign a key the caller sent
		return {
			key,
			upload: await storage.signedUploadUrl(key, {
				expiresIn: 600, // required, seconds
				contentType: input.contentType, // bound into the signature
				maxSize: 200_000_000, // enforced by the provider, see below
			}),
		};
	});
```

That `.access()` line is load-bearing. A route with no rule is reachable by
anyone, the inverse of a collection, and an open presign endpoint hands the
internet write access to your bucket.

The key matters as much. `signedUploadUrl` passes it to the provider verbatim,
so a route that signs whatever key it was handed lets any signed-in caller name
someone else's key and overwrite the bytes under their row. Generate it.

The browser then sends the file to the `upload` that comes back.

| Field         | Type                  | Notes                                          |
| ------------- | --------------------- | ---------------------------------------------- |
| `expiresIn`   | `number`, required    | Seconds the URL stays usable.                  |
| `contentType` | `string`              | Adapters that cannot bind it throw, not warn.  |
| `maxSize`     | `number`              | Bytes. Switches S3 and R2 to a presigned POST. |
| `minSize`     | `number`, default `1` | Pass `0` to allow empty objects.               |

The result is a `SignedUpload`, one of two shapes. `{ method: "PUT", url,
headers? }` is a plain PUT of the file body. `{ method: "POST", url, fields }`
is a multipart form: send every entry of `fields` first, then the file.

<Callout type="warn" title="Always pass `maxSize`">
	Without it, a supporting adapter falls back to a presigned PUT carrying no
	size limit at all, so anyone holding the URL can push an object of any size
	until `expiresIn` elapses. With it, S3 and R2 sign a POST policy whose
	`content-length-range` the provider enforces.
</Callout>

## Then write the row

A presigned upload puts bytes in the bucket and nothing in your database. The
row is still yours to create, with the key you signed.

```ts
await app.collections.media.create(
	{
		key,
		filename: "clip.mp4",
		mimeType: "video/mp4",
		size,
		visibility: "private",
	},
	ctx,
);
```

`key`, `filename`, `mimeType`, `size` and `visibility` are the columns
`.upload()` added, and the first four are nullable, so a row may exist before
its bytes. The write path checks that the object is really there. `afterChange`
calls `storage.exists(key)` on create, or whenever the key changes, and rejects
the write when the object is missing. So finalize after the client reports
success, not before.

<Callout
	type="warn"
	title="`create()` does not apply the collection's visibility"
>
	`collection.upload()` writes `visibility` from `.upload({visibility})`.
	`create()` does not, and the column defaults to `"public"`, so a private
	collection finalized this way hands out unsigned URLs unless you pass
	`visibility` yourself.
</Callout>

<Callout type="warn" title="This path skips the collection's own validation">
	`maxSize` and `allowedTypes` on `.upload()` are enforced by
	`collection.upload()`, along with the blocked-extension list. A presigned
	upload never runs that code. The signed policy is your only server-side gate,
	which is why `maxSize` and `contentType` belong in it.
</Callout>

## The local adapter cannot really sign

`signedUploadUrl` is part of the Files SDK `Adapter` contract, so every adapter
answers it, but `fs()` has no signing primitive. It returns a URL carrying an
`expires` query string that nothing enforces. Treat direct uploads as an
object-storage feature and keep development on the server-mediated route.

## Serving untrusted bytes back

QUESTPIE's own file route already sets `X-Content-Type-Options: nosniff`, sends
`Content-Security-Policy: sandbox` and forces `Content-Disposition: attachment`
for HTML, XHTML and SVG. A provider URL has none of that. When you hand out
`app.storage.url(key)` for user-uploaded content, pass
`responseContentDisposition: "attachment"` so the browser downloads the file
instead of executing it on your origin.

## Related

- [Storage](/docs/infrastructure/storage), adapters, configuration and the `Files` handle.
- [Upload collections](/docs/schema/collections/uploads), the columns and the lifecycle a row goes through.
- [Routes](/docs/code/routes), the builder used above.
- [Client SDK](/docs/client/sdk), the server-mediated upload with progress.
