Sign the PUT, no DB write
Your first job on this surface is the Server Action that hands the browser a short-lived URL for uploading a file straight to R2, with not a single byte passing through your server.
When it works, calling the action from the browser console returns a signed https://<bucket>.r2.cloudflarestorage.com/... URL plus a server-generated upload ID and object key. curl-PUT a file to that URL with the right Content-Type and the object lands in your bucket. There’s no UI this lesson: the proof is a string in the console and an object in the R2 dashboard. The file picker that drives this comes next.
Your mission
Section titled “Your mission”This is the first half of the two-step write, and the action’s whole job is to authorize one upload. It validates what the client claims about the file, decides where the object will live, and signs a time-boxed PutObjectCommand the browser can PUT to directly. It writes no database row: that belongs to finalizeUpload next lesson, after the bytes are confirmed in R2. The asymmetry is the load-bearing decision. An unused presigned PUT leaves an orphan object, which costs a fraction of a cent and gets swept by a lifecycle rule. Writing the row now would instead leave an orphan row whenever a user closes the tab mid-upload: a file_metadata entry pointing at an object that was never finished, so /files renders a download link that 404s. An orphan object is cheap; an orphan row lies to every user who sees it.
Four constraints shape the body. The object key is built server-side from the org and a server-generated UUID, never anything the client sends, so a crafted value can’t target another org’s prefix or collide with an ID finalizeUpload later inserts. The content type is validated against the ALLOWED_CONTENT_TYPES allowlist the client also pre-checks, so the policy has one source of truth. The signed URL expires in five minutes, long enough to push 25 MB up a slow connection, short enough that a leaked URL grants no lasting write. And the action gates on the member role: the R2 credentials are app-wide, so that pin is the only thing between a request and a signed write capability.
You also sign ContentLength from the claimed size, even though R2 won’t enforce that length on the PUT. It documents intent and matches the SDK’s shape, but it is not a size check; the real boundary is next lesson’s post-upload HEAD, which reads the true byte count off the stored object. Reuse buildObjectKey and the authedAction wrapper. Both, plus the schema, are already in the stub, so what you write is the body.
https://<bucket>.r2.cloudflarestorage.com/... URL plus the server-generated upload ID and object key, with no database row written.validation error code and triggers no R2 call.validation error code and triggers no R2 call.curl-PUT a file to the returned URL with the matching Content-Type returns 200 and the object appears in R2 at org/<orgId>/files/<uploadId>.<ext>.curl-PUT with a Content-Type that differs from the signed one is rejected by R2 with 403 SignatureDoesNotMatch.Coding time
Section titled “Coding time”Open src/lib/files/presigned-put.ts and fill the body of presignedPut. The 'use server' directive, the imports, the authedAction('member', …) wrapper, and the Zod schema are already there — you write the four lines that generate the ID, build the key, sign the URL, and return the Result. Try it before opening the walkthrough.
Reference solution and walkthrough
The body is four moves. Read the full file, then step through it below.
'use server';
import { PutObjectCommand } from '@aws-sdk/client-s3';import { getSignedUrl } from '@aws-sdk/s3-request-presigner';import { uuidv7 } from 'uuidv7';import { z } from 'zod';
import { authedAction } from '@/lib/auth/authed-action';import { buildObjectKey } from '@/lib/files/keys';import { ALLOWED_CONTENT_TYPES, BUCKET, MAX_BYTES, r2 } from '@/lib/r2';import { ok, type Result } from '@/lib/result';
export const presignedPut = authedAction( 'member', z.strictObject({ fileName: z.string().min(1).max(255), contentType: z.enum(ALLOWED_CONTENT_TYPES), claimedSize: z.coerce.number().int().positive().max(MAX_BYTES), }), async ( input, ctx, ): Promise<Result<{ uploadId: string; url: string; objectKey: string }>> => { const uploadId = uuidv7(); const objectKey = buildObjectKey({ orgId: ctx.orgId, fileId: uploadId, contentType: input.contentType, });
const url = await getSignedUrl( r2, new PutObjectCommand({ Bucket: BUCKET, Key: objectKey, ContentType: input.contentType, ContentLength: input.claimedSize, }), { signableHeaders: new Set(['content-type']), expiresIn: 300 }, );
return ok({ uploadId, url, objectKey }); },);The Zod schema is the boundary. contentType is z.enum(ALLOWED_CONTENT_TYPES) — the same allowlist lib/r2.ts exports and the client pre-checks. claimedSize coerces to a positive integer capped at MAX_BYTES (25 MB), and strictObject rejects extra keys. authedAction runs safeParse before your body, so a bad type or over-cap size never reaches the code below — it short-circuits with err('validation', …), the same Zod boundary your forms use.
'use server';
import { PutObjectCommand } from '@aws-sdk/client-s3';import { getSignedUrl } from '@aws-sdk/s3-request-presigner';import { uuidv7 } from 'uuidv7';import { z } from 'zod';
import { authedAction } from '@/lib/auth/authed-action';import { buildObjectKey } from '@/lib/files/keys';import { ALLOWED_CONTENT_TYPES, BUCKET, MAX_BYTES, r2 } from '@/lib/r2';import { ok, type Result } from '@/lib/result';
export const presignedPut = authedAction( 'member', z.strictObject({ fileName: z.string().min(1).max(255), contentType: z.enum(ALLOWED_CONTENT_TYPES), claimedSize: z.coerce.number().int().positive().max(MAX_BYTES), }), async ( input, ctx, ): Promise<Result<{ uploadId: string; url: string; objectKey: string }>> => { const uploadId = uuidv7(); const objectKey = buildObjectKey({ orgId: ctx.orgId, fileId: uploadId, contentType: input.contentType, });
const url = await getSignedUrl( r2, new PutObjectCommand({ Bucket: BUCKET, Key: objectKey, ContentType: input.contentType, ContentLength: input.claimedSize, }), { signableHeaders: new Set(['content-type']), expiresIn: 300 }, );
return ok({ uploadId, url, objectKey }); },);uuidv7() mints the upload ID: time-sortable, never client-supplied. buildObjectKey derives the key from ctx.orgId (the authenticated org, not an input field) plus that ID and the content type: org/<orgId>/files/<uploadId>.<ext>. A client-chosen key or ID is the tenancy-bypass shape; building it server-side closes that door.
'use server';
import { PutObjectCommand } from '@aws-sdk/client-s3';import { getSignedUrl } from '@aws-sdk/s3-request-presigner';import { uuidv7 } from 'uuidv7';import { z } from 'zod';
import { authedAction } from '@/lib/auth/authed-action';import { buildObjectKey } from '@/lib/files/keys';import { ALLOWED_CONTENT_TYPES, BUCKET, MAX_BYTES, r2 } from '@/lib/r2';import { ok, type Result } from '@/lib/result';
export const presignedPut = authedAction( 'member', z.strictObject({ fileName: z.string().min(1).max(255), contentType: z.enum(ALLOWED_CONTENT_TYPES), claimedSize: z.coerce.number().int().positive().max(MAX_BYTES), }), async ( input, ctx, ): Promise<Result<{ uploadId: string; url: string; objectKey: string }>> => { const uploadId = uuidv7(); const objectKey = buildObjectKey({ orgId: ctx.orgId, fileId: uploadId, contentType: input.contentType, });
const url = await getSignedUrl( r2, new PutObjectCommand({ Bucket: BUCKET, Key: objectKey, ContentType: input.contentType, ContentLength: input.claimedSize, }), { signableHeaders: new Set(['content-type']), expiresIn: 300 }, );
return ok({ uploadId, url, objectKey }); },);getSignedUrl signs the PutObjectCommand into an upload capability scoped to this exact Bucket, Key, and ContentType. This is pure local HMAC — no round trip to R2 — so it returns instantly. Two options carry weight, below.
'use server';
import { PutObjectCommand } from '@aws-sdk/client-s3';import { getSignedUrl } from '@aws-sdk/s3-request-presigner';import { uuidv7 } from 'uuidv7';import { z } from 'zod';
import { authedAction } from '@/lib/auth/authed-action';import { buildObjectKey } from '@/lib/files/keys';import { ALLOWED_CONTENT_TYPES, BUCKET, MAX_BYTES, r2 } from '@/lib/r2';import { ok, type Result } from '@/lib/result';
export const presignedPut = authedAction( 'member', z.strictObject({ fileName: z.string().min(1).max(255), contentType: z.enum(ALLOWED_CONTENT_TYPES), claimedSize: z.coerce.number().int().positive().max(MAX_BYTES), }), async ( input, ctx, ): Promise<Result<{ uploadId: string; url: string; objectKey: string }>> => { const uploadId = uuidv7(); const objectKey = buildObjectKey({ orgId: ctx.orgId, fileId: uploadId, contentType: input.contentType, });
const url = await getSignedUrl( r2, new PutObjectCommand({ Bucket: BUCKET, Key: objectKey, ContentType: input.contentType, ContentLength: input.claimedSize, }), { signableHeaders: new Set(['content-type']), expiresIn: 300 }, );
return ok({ uploadId, url, objectKey }); },);The action returns the URL plus the ID and key the browser needs to PUT and then finalize. No insert, no database touch: the row is next lesson’s job.
signableHeaders: new Set(['content-type']) is what enforces the content type. It forces content-type into the signature. R2 recomputes the signature from whatever Content-Type the PUT actually carries; if it differs from the one you signed, the match fails and R2 answers 403 SignatureDoesNotMatch — the failure you’ll trigger by hand in a moment. Without it, a signed URL accepts any content type, and the extension in your key no longer matches the bytes.
expiresIn: 300 (five minutes) is a trade-off. The window must outlast a 25 MB upload on a weak connection, yet every extra minute keeps a leaked URL live as a write capability. Five minutes clears the upload; a failed client just re-signs. The ContentLength beside it is signed but, as you saw in Presigned URLs, R2 won’t reject a PUT that exceeds it — documentation of intent, not a guard. The real size check is next lesson’s HEAD on the stored object.
The key and extension come from two small helpers in lib/files/keys.ts, first used here:
import type { ALLOWED_CONTENT_TYPES } from '@/lib/r2';
type AllowedContentType = (typeof ALLOWED_CONTENT_TYPES)[number];
const EXT_FOR: Record<AllowedContentType, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'application/pdf': 'pdf', 'text/csv': 'csv',};
export const extFor = (contentType: AllowedContentType): string => EXT_FOR[contentType];
export const buildObjectKey = ({ orgId, fileId, contentType,}: { orgId: string; fileId: string; contentType: AllowedContentType;}): string => `org/${orgId}/files/${fileId}.${extFor(contentType)}`;The extension comes from the validated content type, never the user’s file name, so a .exe renamed .png can’t smuggle its real extension into your key. Note extFor('image/jpeg') returns 'jpg', not 'jpeg': a curl-PUT JPEG lands at ...<uploadId>.jpg.
Cloudflare R2 reference for signing a PutObjectCommand, including the Content-Type binding you trigger by hand.
Configuring the S3 client against R2's endpoint and the s3-request-presigner this action uses.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 2It drives the contract three ways: a valid call returns a signed URL with a server-built ID and key and writes no row; a disallowed content type returns validation with no R2 call; an over-cap claimedSize returns validation. A pass looks like this:
[32m✓[0m Lesson 2 — presignedPut signs a 5-min PUT and writes no DB row [2m(10)[0m [32m✓[0m valid upload request → signed URL + server-built id/key, no row [2m(5)[0m [32m✓[0m disallowed content type → validation, no R2 call [2m(3)[0m [32m✓[0m claimed size over the cap → validation, no R2 call [2m(2)[0m
[2mTest Files[0m [32m1 passed[0m [2m(1)[0m [2m Tests[0m [32m10 passed[0m [2m(10)[0mThe tests prove the shape and the boundary but can’t reach a live bucket, so confirm the byte transfer and the content-type binding by hand. Sign in as a member of a seeded org, open the browser console, and call presignedPut (a raw curl to the action won’t work — it needs your session). Copy the returned url, then:
curl -X PUT -H "Content-Type: image/jpeg" --data-binary @some.jpg "<signed-url>"curl-PUT above returns 200, and the object appears in the R2 dashboard at org/<orgId>/files/<uploadId>.jpg.curl with a mismatched -H "Content-Type: image/png" against the same JPEG-signed URL is rejected by R2 with 403 SignatureDoesNotMatch.The second check is the payoff for signableHeaders: the signature is bound to image/jpeg, so R2 rejects the image/png PUT. Both green means a working, scoped, time-boxed write the server never touched. Next lesson, a file picker drives this and the row lands once the upload is confirmed.