---
title: "Upload files with S3 or getqr.id"
description: "Add private avatar uploads to a Next.js app on S3 or getqr.id, test them offline, and swap stores by changing one line of main.yaml."
section: "guides"
status: "Building for 0.3"
spec: "files-share"
canonical: "https://mainsdk.dev/docs/guides/file-uploads"
updated: "2026-10-28"
---

# Upload files with S3 or getqr.id

> Add private avatar uploads to a Next.js app on S3 or getqr.id, test them offline, and swap stores by changing one line of main.yaml.

Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/file-uploads · Updated: 2026-10-28

You will add an avatar upload to Pinecart, a Next.js app on MainSDK: the browser sends the file straight to the store, `files.complete()` scans it, and only its owner can read it. You pick a store, write two routes and one browser function, test it offline, and finish with a passing `mainsdk check`. It takes about 20 minutes, and you can change the store later by editing one line.

> **Note:** `files` (spec `files-share`, ADR-0013) runs from the repository and is not published to npm yet. The `getqr`, `s3`, `local` and `memory` drivers are built; `uploadthing` and `vercel-blob` are planned and fail with `MAIN-FILES-0014` today.

## You need

- A Next.js App Router app with a `main.yaml`, `auth` and `db` set up (see [Quickstart](https://mainsdk.dev/docs/getting-started/quickstart)).
- An account and secret for the store you pick: a getqr.id API key, or an S3 bucket and access key.
- Secrets stored as references in your secrets manager, never in code (see [Secrets](https://mainsdk.dev/docs/interfaces/secrets)).

## Pick a driver

`files` has one contract and several drivers. Every driver keeps the same guarantees (ADR-0013): owner-scoped reads, short-lived signed URLs, a scan before a file is readable, resumable uploads and an idempotency key. A driver that cannot meet one declares the gap, and `mainsdk check` names it.

| Driver | Pick it when | Private files | Notes |
| --- | --- | --- | --- |
| `getqr` | You want the default, with uploads, share links and QR codes in one place. HubXero products use it. | Owner-scoped, signed URLs | Needs a getqr.id API key. getqr.id scans server side, and its upload target is a tus endpoint (PATCH), so the browser needs a tus-aware client such as `<Upload/>` from `@getqr/react`. [Setup](https://mainsdk.dev/docs/integrations/getqr-id) |
| `s3` | You already have a bucket, need its region, or want files in your own cloud account. Also R2, MinIO and Backblaze B2. | Owner-scoped, signed URLs. Keep Block all public access on. | Browser uploads need a CORS rule on the bucket. On AWS you can use the runtime's IAM role and set no keys. [Setup](https://mainsdk.dev/docs/integrations/amazon-s3) |
| `uploadthing` | Planned. | Not built yet (`MAIN-FILES-0014`). | [Setup](https://mainsdk.dev/docs/integrations/uploadthing) |
| `vercel-blob` | Planned. | Not built yet (`MAIN-FILES-0014`). | [Setup](https://mainsdk.dev/docs/integrations/vercel-blob) |
| `local` | Development and tests, with no account. | Owner-scoped | Not for production. |

Sharing is separate from storage. `share()`, links and QR codes always use getqr.id, whichever driver holds the file (ADR-0009). See [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share).

## 1. Declare the driver in main.yaml

Pick one block. Each `key` is a `secret:` reference, so no secret is in the file. A literal value fails with `MAIN-CONFIG-0004`. For S3, `access-key-id` is an identifier, not a secret, so it may be a literal.

```yaml title="main.yaml (getqr.id)"
files: { driver: getqr, key: secret:GETQR_API_KEY, scan: true, max-size: 25MB }
```

```yaml title="main.yaml (Amazon S3)"
files: { driver: s3, bucket: acme-uploads, region: eu-west-1, access-key-id: AKIA…, key: secret:S3_SECRET_ACCESS_KEY, scan: true }
# on AWS, omit access-key-id and key to use the runtime's IAM role
# R2, MinIO and B2 also set: endpoint: https://<their-endpoint>
```

The S3 `region` is the bucket's own region: pick a bucket in the region your users' data must stay in. For S3, create the bucket with Block all public access on and add the CORS rule from [Amazon S3](https://mainsdk.dev/docs/integrations/amazon-s3), or the browser cannot upload.

You should see: `npx mainsdk check` passes, or names a missing secret.

## 2. Use the local driver in development

A dev overlay means you need no account to build. See [main.yaml and overlays](https://mainsdk.dev/docs/concepts/main-yaml).

```yaml title="main.env.dev.yaml"
files:
  driver: local
```

You should see: `npx mainsdk dev -- npm run dev` stores files on disk with no network call.

## 3. Issue an upload on the server

`files.uploadUrl()` returns an `Upload`: the URL, the HTTP method, the headers or form fields to send, and a pending `FileRef` in `upload.ref`. It is signed, single-use, limited in size and type, and expires in 15 minutes. The bytes never pass through your server.

```ts title="app/api/uploads/route.ts"
import { auth, files } from "mainsdk";

export async function POST(req: Request) {
  const session = await auth.require(req);               // 401 with a fix if not signed in
  const upload = await files.uploadUrl({
    owner: session.user,
    maxSize: "5MB",
    types: ["image/png", "image/jpeg"],
  });
  return Response.json(upload);                          // { method, url, headers?, fields?, ref }
}
```

You should see: a signed-in `POST /api/uploads` returns `method`, `url` and `ref` with `ref.status` set to `"pending"`, plus `headers` (for `PUT`) or `fields` (for `POST`).

## 4. Upload from the browser

This is the one browser function for every driver. The other pages link here. For `PUT` it sends the bytes as the body with `upload.headers`. For `POST` it sends a form with `upload.fields` first and the file last.

```ts title="app/profile/upload-file.ts"
import type { Upload } from "mainsdk/files";

export async function uploadFile(file: File) {
  const res = await fetch("/api/uploads", { method: "POST" });
  const upload = (await res.json()) as Upload;

  if (upload.method === "PUT") {
    await fetch(upload.url, { method: "PUT", headers: upload.headers, body: file });
  } else {
    const form = new FormData();
    for (const [name, value] of Object.entries(upload.fields ?? {})) form.append(name, value as string);
    form.append("file", file);                           // the file goes last in a form upload
    await fetch(upload.url, { method: "POST", body: form });
  }

  const done = await fetch(`/api/uploads/${upload.ref.id}/complete`, { method: "POST" });
  return (await done.json()) as { url: string };
}
```

The store refuses a file over `maxSize` or outside `types` (`MAIN-FILES-0006`, `MAIN-FILES-0007`), and the signed URL works once (`MAIN-FILES-0004` after that). With the getqr.id driver you can use `<Upload/>` from `@getqr/react` instead of this function (see [File uploads](https://mainsdk.dev/docs/interfaces/files)).

You should see: a 6 MB file or a `text/html` file is refused by the store, and a small PNG goes through.

## 5. Complete the upload and read the file

`files.complete()` runs the scan and only then makes the file readable. `files.url()` returns a short-lived URL for its owner. Pass it `upload.ref.id`, which arrives here as the route's `id`.

```ts title="app/api/uploads/[id]/complete/route.ts"
import { auth, files } from "mainsdk";

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await auth.require(req);
  const { id } = await params;
  const ref = await files.complete(id, { owner: session.user }); // only the uploader can finish it
  const url = await files.url(ref, { for: session.user, expiresIn: "1h" });
  return Response.json({ url });
}
```

Save `ref` on your own record here if the file belongs to one, for example the user's avatar.

You should see: the response holds a URL that stops working after an hour. A reader that `access` denies gets `MAIN-FILES-0002` with status 403. Another user who sends someone else's upload id to the complete route also gets `MAIN-FILES-0002`, and the upload stays pending. An infected file makes `files.complete()` fail with `MAIN-FILES-0005`: the file is quarantined and no URL exists.

## 6. Test it offline

`createTestApp()` puts `files` on its in-memory fake, with the same owner scope and scanning as production. `testApp.files.put(upload, bytes, { type })` plays the browser's part, so the test covers the direct flow with no network. It covers a denied reader (`files-share AC1`) and the EICAR test file (`files-share AC2`).

```ts title="app/api/uploads/uploads.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { files } from "mainsdk";

const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const eicar = new TextEncoder().encode(
  "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*",
);

describe("uploads", () => {
  it("files-share AC1 refuses a reader access denies", async () => {
    const testApp = createTestApp({ main: "./main.yaml" });
    const upload = await files.uploadUrl({ owner: { id: "u1" }, maxSize: "5MB", types: ["image/png"] });
    await testApp.files.put(upload, png, { type: "image/png" });
    const ref = await files.complete(upload.ref.id, { owner: { id: "u1" } });
    await assert.rejects(files.url(ref, { for: { id: "u2" } }), { code: "MAIN-FILES-0002" });
  });

  it("files-share AC2 refuses an infected file", async () => {
    const testApp = createTestApp({ main: "./main.yaml" });
    const upload = await files.uploadUrl({ owner: { id: "u1" }, maxSize: "5MB", types: ["text/plain"] });
    await testApp.files.put(upload, eicar, { type: "text/plain" });
    await assert.rejects(files.complete(upload.ref.id, { owner: { id: "u1" } }), { code: "MAIN-FILES-0005" });
  });
});
```

Use `clock.advance("61m")` to expire a one-hour signed URL without waiting.

## Verify

```bash
npx mainsdk check
```

Add `--env prod` to check the production overlay. A passing run looks like this:

```text
$ npx mainsdk check
mainsdk check · notes · env dev · Main 27

  ✓ config   ✓ secrets   ✓ policy   ✓ network   ✓ auth   ✓ data

Passed · 6 gates · 0 findings · 1.2s
```

Import `@aws-sdk/client-s3`, `uploadthing` or `@vercel/blob` in a route and the check fails with `MAIN-FILES-0010` and names the file. Replace the import with `files.upload()` or `files.uploadUrl()`.

## If it fails

| Code | Cause | Fix |
| --- | --- | --- |
| `MAIN-FILES-0002` | `files.url()` was called for a reader `access` denies (403). | Read as the owner, or add an `access` rule for the reader. |
| `MAIN-FILES-0005` | The scan in `files.complete()` found an infected file. The upload fails, the file is quarantined and no URL exists. | Ask the user for a clean file. Nothing to fix in code. |
| `MAIN-FILES-0010` | `mainsdk check` found a storage SDK imported in route code. | Use `files.upload()` or `files.uploadUrl()`, or set that store as the `files` driver. |
| `MAIN-CONFIG-0004` | `key` is a literal value, not a `secret:` reference. | Move the value to your secrets manager and reference it. |
| `MAIN-CHECK-0026` | `files.driver` is still `local` in production. | Set a real driver in `main.yaml` and keep `local` only in `main.env.dev.yaml`. |

Run `npx mainsdk explain MAIN-FILES-0005` for the cause and fix offline.

## Swap drivers later

Change `driver` and its keys. `files.uploadUrl()`, `files.complete()` and `files.url()` stay the same, and so do owner scope, scanning and audit.

```yaml title="main.yaml"
files: { driver: s3, bucket: acme-uploads, region: eu-west-1, access-key-id: AKIA…, key: secret:S3_SECRET_ACCESS_KEY, scan: true }   # was getqr
```

Existing files stay where they are until you migrate them, and old `FileRef` values keep pointing at the old store. How a migration moves them is [TBD]. See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver).

## For agents

```text
Goal: add owner-scoped avatar upload to this Next.js app with the files interface.
Read: docs/specs/files-share.md, the driver's integration page, main.yaml.
Touch only: main.yaml, main.env.dev.yaml, app/api/uploads/**, app/profile/upload-file.ts, one test file.
Do: declare files.driver with a secret: reference; add a dev overlay with driver local;
issue the upload with files.uploadUrl({ owner, maxSize, types }) in POST /api/uploads;
send the file to upload.url with upload.method (the browser function in the guide);
call files.complete(upload.ref.id, { owner }) as the same user before files.url(ref, { for, expiresIn }).
Never import @aws-sdk/client-s3, uploadthing or @vercel/blob in route code.
Never put a key in code. Never weaken, skip or loosen a check to make it pass.
Verify: npx mainsdk check --json --agent exits 0, and the test names its criterion (files-share AC1).
```

## Next

- [File uploads](https://mainsdk.dev/docs/interfaces/files)
- [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share)
- [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver)
