Browser PUT, HEAD, then insert
Last lesson ended at a signed URL. This lesson turns it into a working upload: a file picked on /files streams straight to R2 with a live progress bar, and the app records it.
When it works, choosing a file walks the status text through signing → uploading → finalizing → done as the bar fills. The payoff is one row in file_metadata plus one file.uploaded audit entry, and the size and content type on that row are read back off the stored object, not trusted from whatever the file picker reported. That read-back is the point of the lesson.
Your mission
Section titled “Your mission”finalizeUpload is the second half of last lesson’s two-step write. It has nothing to finalize without a browser to drive the upload, and the upload form has nowhere to record its result without finalizeUpload, so you build both. The file_metadata table and its migration already shipped in the starter (pnpm db:migrate in Setup), so you insert into a table that exists.
The load-bearing idea is the trust boundary. After the browser reports a successful PUT, finalizeUpload ignores the report, issues a HeadObjectCommand against the object, and inserts the row from the content length and content type R2 actually stored, never from the request body. The insert and the audit write share one tenantDb transaction, so the row and its file.uploaded entry commit together or not at all. This is where last lesson’s size defense pays off: R2 does not enforce the signed ContentLength, so a client that signs a small claimedSize then PUTs a 50 MB body sails past signing and is caught here by the HEAD. The unique constraint on objectKey is the second layer: a replayed finalize trips it and returns a conflict instead of a duplicate row.
The browser side introduces one new tool, XMLHttpRequest, for one reason: only XHR exposes upload progress, through xhr.upload.onprogress, and without it there is no bar to fill. Its PUT must send the exact Content-Type that was signed, because the signature is bound to that header; a browser that normalizes .JPG to image/pjpeg produces a 403 SignatureDoesNotMatch that looks nothing like a type problem. The client’s own size and type pre-checks are for instant feedback, rejecting a 50 MB drop before any network call, not a stand-in for the server boundary. Accept two edges: HEAD-then-insert is not transactional with R2, so a never-finalized object exists briefly with no row and is swept by the lifecycle rules; and a 4xx on the PUT means re-running from a fresh signed URL, not reusing the dead one. You reuse logAudit and the UploadError codes from earlier work. The file list and its download links are next lesson, so leave a minimal empty state where the list will go.
/files runs the status through signing → uploading → finalizing → done with a smooth progress bar.file_metadata row exists with byteSize and contentType taken from the post-upload HEAD and uploadedBy set to the current user.<bucket>.r2.cloudflarestorage.com.size-mismatch, and no row is inserted.file.uploaded audit entry, committed in the same transaction as the row.localhost origin succeeds.Coding time
Section titled “Coding time”Three things to write. Fill in finalizeUpload in src/lib/files/finalize.ts, build the UploadForm client component in src/app/files/upload-form.tsx, and mount it in src/app/files/page.tsx. Work from the brief and the tests before opening the walkthrough.
Reference solution and walkthrough
Three files, in the order the upload flows through them: the server boundary, the browser form that drives it, then the page that mounts the form.
finalizeUpload — HEAD, validate, then insert
Section titled “finalizeUpload — HEAD, validate, then insert”The trust boundary: HEAD the object, reject anything that disagrees with what was signed, then insert the row and audit entry in one transaction. A replay maps to a conflict.
const isMissingObject = (e: unknown): boolean => { if (typeof e !== 'object' || e === null) { return false; } const name = (e as { name?: unknown }).name; const status = (e as { $metadata?: { httpStatusCode?: unknown } }).$metadata ?.httpStatusCode; return name === 'NotFound' || name === 'NoSuchKey' || status === 404;};
export const finalizeUpload = authedAction( 'member', z.strictObject({ uploadId: z.uuid(), objectKey: z.string().min(1), originalFileName: z.string().min(1).max(255), contentType: z.enum(ALLOWED_CONTENT_TYPES), }), async (input, ctx): Promise<Result<{ fileId: string }>> => { let head: HeadObjectCommandOutput; try { head = await r2.send( new HeadObjectCommand({ Bucket: BUCKET, Key: input.objectKey }), ); } catch (e) { if (isMissingObject(e)) { return UploadError.toResult( new UploadError('object-not-found', 'The upload did not complete.'), ); } throw e; }
if (head.ContentType !== input.contentType) { return UploadError.toResult( new UploadError( 'size-mismatch', 'The uploaded file did not match what was signed.', ), ); }
const byteSize = head.ContentLength ?? 0; if (byteSize > MAX_BYTES) { return UploadError.toResult( new UploadError('size-mismatch', 'The uploaded file is too large.'), ); }
try { await tenantDb(ctx.orgId).transaction(async (tx) => { await tx.insert(fileMetadata).values({ id: input.uploadId, organizationId: ctx.orgId, uploadedBy: ctx.user.id, objectKey: input.objectKey, originalFileName: input.originalFileName, contentType: head.ContentType ?? input.contentType, byteSize, });
await logAudit(tx, { action: 'file.uploaded', subjectType: 'file', subjectId: input.uploadId, payload: { byteSize, contentType: head.ContentType }, }); }); } catch (e) { if (isUniqueViolation(e)) { return err('conflict', 'This file has already been finalized.'); } throw e; }
return ok({ fileId: input.uploadId }); },);The HEAD comes first: read storage before writing anything. HeadObjectCommand fetches the object’s metadata without its body, which is all you need here — its size and type, not its bytes. If the object isn’t there, the SDK throws.
const isMissingObject = (e: unknown): boolean => { if (typeof e !== 'object' || e === null) { return false; } const name = (e as { name?: unknown }).name; const status = (e as { $metadata?: { httpStatusCode?: unknown } }).$metadata ?.httpStatusCode; return name === 'NotFound' || name === 'NoSuchKey' || status === 404;};
export const finalizeUpload = authedAction( 'member', z.strictObject({ uploadId: z.uuid(), objectKey: z.string().min(1), originalFileName: z.string().min(1).max(255), contentType: z.enum(ALLOWED_CONTENT_TYPES), }), async (input, ctx): Promise<Result<{ fileId: string }>> => { let head: HeadObjectCommandOutput; try { head = await r2.send( new HeadObjectCommand({ Bucket: BUCKET, Key: input.objectKey }), ); } catch (e) { if (isMissingObject(e)) { return UploadError.toResult( new UploadError('object-not-found', 'The upload did not complete.'), ); } throw e; }
if (head.ContentType !== input.contentType) { return UploadError.toResult( new UploadError( 'size-mismatch', 'The uploaded file did not match what was signed.', ), ); }
const byteSize = head.ContentLength ?? 0; if (byteSize > MAX_BYTES) { return UploadError.toResult( new UploadError('size-mismatch', 'The uploaded file is too large.'), ); }
try { await tenantDb(ctx.orgId).transaction(async (tx) => { await tx.insert(fileMetadata).values({ id: input.uploadId, organizationId: ctx.orgId, uploadedBy: ctx.user.id, objectKey: input.objectKey, originalFileName: input.originalFileName, contentType: head.ContentType ?? input.contentType, byteSize, });
await logAudit(tx, { action: 'file.uploaded', subjectType: 'file', subjectId: input.uploadId, payload: { byteSize, contentType: head.ContentType }, }); }); } catch (e) { if (isUniqueViolation(e)) { return err('conflict', 'This file has already been finalized.'); } throw e; }
return ok({ fileId: input.uploadId }); },);A missing object means the upload never landed, so it maps to object-not-found. isMissingObject matches the SDK’s NotFound/NoSuchKey name or a 404 status rather than importing the exception class, and re-throws anything else — a network blip or an auth failure stays a real error instead of being swallowed as “file not found.”
const isMissingObject = (e: unknown): boolean => { if (typeof e !== 'object' || e === null) { return false; } const name = (e as { name?: unknown }).name; const status = (e as { $metadata?: { httpStatusCode?: unknown } }).$metadata ?.httpStatusCode; return name === 'NotFound' || name === 'NoSuchKey' || status === 404;};
export const finalizeUpload = authedAction( 'member', z.strictObject({ uploadId: z.uuid(), objectKey: z.string().min(1), originalFileName: z.string().min(1).max(255), contentType: z.enum(ALLOWED_CONTENT_TYPES), }), async (input, ctx): Promise<Result<{ fileId: string }>> => { let head: HeadObjectCommandOutput; try { head = await r2.send( new HeadObjectCommand({ Bucket: BUCKET, Key: input.objectKey }), ); } catch (e) { if (isMissingObject(e)) { return UploadError.toResult( new UploadError('object-not-found', 'The upload did not complete.'), ); } throw e; }
if (head.ContentType !== input.contentType) { return UploadError.toResult( new UploadError( 'size-mismatch', 'The uploaded file did not match what was signed.', ), ); }
const byteSize = head.ContentLength ?? 0; if (byteSize > MAX_BYTES) { return UploadError.toResult( new UploadError('size-mismatch', 'The uploaded file is too large.'), ); }
try { await tenantDb(ctx.orgId).transaction(async (tx) => { await tx.insert(fileMetadata).values({ id: input.uploadId, organizationId: ctx.orgId, uploadedBy: ctx.user.id, objectKey: input.objectKey, originalFileName: input.originalFileName, contentType: head.ContentType ?? input.contentType, byteSize, });
await logAudit(tx, { action: 'file.uploaded', subjectType: 'file', subjectId: input.uploadId, payload: { byteSize, contentType: head.ContentType }, }); }); } catch (e) { if (isUniqueViolation(e)) { return err('conflict', 'This file has already been finalized.'); } throw e; }
return ok({ fileId: input.uploadId }); },);The type the HEAD reports must equal the one that was signed. A different stored type means the object is not what you authorized, so size-mismatch. This is the type half of the boundary, checked against head.ContentType, never the client’s claim.
const isMissingObject = (e: unknown): boolean => { if (typeof e !== 'object' || e === null) { return false; } const name = (e as { name?: unknown }).name; const status = (e as { $metadata?: { httpStatusCode?: unknown } }).$metadata ?.httpStatusCode; return name === 'NotFound' || name === 'NoSuchKey' || status === 404;};
export const finalizeUpload = authedAction( 'member', z.strictObject({ uploadId: z.uuid(), objectKey: z.string().min(1), originalFileName: z.string().min(1).max(255), contentType: z.enum(ALLOWED_CONTENT_TYPES), }), async (input, ctx): Promise<Result<{ fileId: string }>> => { let head: HeadObjectCommandOutput; try { head = await r2.send( new HeadObjectCommand({ Bucket: BUCKET, Key: input.objectKey }), ); } catch (e) { if (isMissingObject(e)) { return UploadError.toResult( new UploadError('object-not-found', 'The upload did not complete.'), ); } throw e; }
if (head.ContentType !== input.contentType) { return UploadError.toResult( new UploadError( 'size-mismatch', 'The uploaded file did not match what was signed.', ), ); }
const byteSize = head.ContentLength ?? 0; if (byteSize > MAX_BYTES) { return UploadError.toResult( new UploadError('size-mismatch', 'The uploaded file is too large.'), ); }
try { await tenantDb(ctx.orgId).transaction(async (tx) => { await tx.insert(fileMetadata).values({ id: input.uploadId, organizationId: ctx.orgId, uploadedBy: ctx.user.id, objectKey: input.objectKey, originalFileName: input.originalFileName, contentType: head.ContentType ?? input.contentType, byteSize, });
await logAudit(tx, { action: 'file.uploaded', subjectType: 'file', subjectId: input.uploadId, payload: { byteSize, contentType: head.ContentType }, }); }); } catch (e) { if (isUniqueViolation(e)) { return err('conflict', 'This file has already been finalized.'); } throw e; }
return ok({ fileId: input.uploadId }); },);The size half. byteSize comes off head.ContentLength — the real measured bytes — with a 0 fallback, and a body over MAX_BYTES is rejected before any insert. This catches the lying client from last lesson: R2 never enforced the signed ContentLength, but the stored object’s real length is here for the reading.
const isMissingObject = (e: unknown): boolean => { if (typeof e !== 'object' || e === null) { return false; } const name = (e as { name?: unknown }).name; const status = (e as { $metadata?: { httpStatusCode?: unknown } }).$metadata ?.httpStatusCode; return name === 'NotFound' || name === 'NoSuchKey' || status === 404;};
export const finalizeUpload = authedAction( 'member', z.strictObject({ uploadId: z.uuid(), objectKey: z.string().min(1), originalFileName: z.string().min(1).max(255), contentType: z.enum(ALLOWED_CONTENT_TYPES), }), async (input, ctx): Promise<Result<{ fileId: string }>> => { let head: HeadObjectCommandOutput; try { head = await r2.send( new HeadObjectCommand({ Bucket: BUCKET, Key: input.objectKey }), ); } catch (e) { if (isMissingObject(e)) { return UploadError.toResult( new UploadError('object-not-found', 'The upload did not complete.'), ); } throw e; }
if (head.ContentType !== input.contentType) { return UploadError.toResult( new UploadError( 'size-mismatch', 'The uploaded file did not match what was signed.', ), ); }
const byteSize = head.ContentLength ?? 0; if (byteSize > MAX_BYTES) { return UploadError.toResult( new UploadError('size-mismatch', 'The uploaded file is too large.'), ); }
try { await tenantDb(ctx.orgId).transaction(async (tx) => { await tx.insert(fileMetadata).values({ id: input.uploadId, organizationId: ctx.orgId, uploadedBy: ctx.user.id, objectKey: input.objectKey, originalFileName: input.originalFileName, contentType: head.ContentType ?? input.contentType, byteSize, });
await logAudit(tx, { action: 'file.uploaded', subjectType: 'file', subjectId: input.uploadId, payload: { byteSize, contentType: head.ContentType }, }); }); } catch (e) { if (isUniqueViolation(e)) { return err('conflict', 'This file has already been finalized.'); } throw e; }
return ok({ fileId: input.uploadId }); },);The insert and the audit write share one tenantDb(ctx.orgId).transaction. The row is keyed to input.uploadId — the same server-generated UUID baked into the object key last lesson, so the row and its object share one identity. byteSize and contentType come from the HEAD, uploadedBy from ctx.user.id, never a request field. Passing tx to logAudit binds the audit entry to the same atomic unit: both land or neither does.
const isMissingObject = (e: unknown): boolean => { if (typeof e !== 'object' || e === null) { return false; } const name = (e as { name?: unknown }).name; const status = (e as { $metadata?: { httpStatusCode?: unknown } }).$metadata ?.httpStatusCode; return name === 'NotFound' || name === 'NoSuchKey' || status === 404;};
export const finalizeUpload = authedAction( 'member', z.strictObject({ uploadId: z.uuid(), objectKey: z.string().min(1), originalFileName: z.string().min(1).max(255), contentType: z.enum(ALLOWED_CONTENT_TYPES), }), async (input, ctx): Promise<Result<{ fileId: string }>> => { let head: HeadObjectCommandOutput; try { head = await r2.send( new HeadObjectCommand({ Bucket: BUCKET, Key: input.objectKey }), ); } catch (e) { if (isMissingObject(e)) { return UploadError.toResult( new UploadError('object-not-found', 'The upload did not complete.'), ); } throw e; }
if (head.ContentType !== input.contentType) { return UploadError.toResult( new UploadError( 'size-mismatch', 'The uploaded file did not match what was signed.', ), ); }
const byteSize = head.ContentLength ?? 0; if (byteSize > MAX_BYTES) { return UploadError.toResult( new UploadError('size-mismatch', 'The uploaded file is too large.'), ); }
try { await tenantDb(ctx.orgId).transaction(async (tx) => { await tx.insert(fileMetadata).values({ id: input.uploadId, organizationId: ctx.orgId, uploadedBy: ctx.user.id, objectKey: input.objectKey, originalFileName: input.originalFileName, contentType: head.ContentType ?? input.contentType, byteSize, });
await logAudit(tx, { action: 'file.uploaded', subjectType: 'file', subjectId: input.uploadId, payload: { byteSize, contentType: head.ContentType }, }); }); } catch (e) { if (isUniqueViolation(e)) { return err('conflict', 'This file has already been finalized.'); } throw e; }
return ok({ fileId: input.uploadId }); },);The second defense layer. A finalize replayed against an already-finalized upload trips the unique(objectKey) constraint with a 23505; isUniqueViolation catches it and returns err('conflict', …) instead of a duplicate row. Anything else re-throws.
Four of these choices matter beyond what the code shows.
The HEAD is the real size boundary. R2 ignores the ContentLength you signed into the PUT URL (the quirk from Presigned URLs), so a client can sign a 1 KB claim and PUT 50 MB. The true size surfaces only when you HEAD the stored object, which is why byteSize and the cap check read head.ContentLength, never input.
Both checks return before the insert. The type and size checks return above the transaction, so a mismatched object leaves zero rows behind. Insert first and an over-cap upload would write a row only to delete it, and a crash in between would leave exactly the orphan row this two-step write exists to prevent.
The audit row rides the same transaction. logAudit takes tx, not the ambient db, so “a file exists” and “we recorded the upload” are one write and can never disagree (the append-only audit log discipline). The first argument is typed as the transaction, so an off-transaction call won’t compile.
HEAD-then-insert isn’t transactional with R2, and that’s fine. Between the HEAD succeeding and the row committing, the object exists with no row pointing at it. That window is microseconds, and the object is harmless: it renders nowhere, and a lifecycle rule sweeps unreferenced objects. An orphan object is cheap, an orphan row is a lie, so the design tolerates the gap instead of reaching for a distributed transaction R2 doesn’t offer.
Here is the full file, including the imports the stub already has and the isMissingObject helper the walkthrough skipped:
'use server';
import { HeadObjectCommand, type HeadObjectCommandOutput,} from '@aws-sdk/client-s3';import { z } from 'zod';
import { logAudit } from '@/db/audit-log';import { fileMetadata } from '@/db/schema';import { tenantDb } from '@/db/tenant';import { authedAction } from '@/lib/auth/authed-action';import { UploadError } from '@/lib/files/errors';import { ALLOWED_CONTENT_TYPES, BUCKET, MAX_BYTES, r2 } from '@/lib/r2';import { err, isUniqueViolation, ok, type Result } from '@/lib/result';
// A 404 surfaces on the HEAD as the SDK's `NotFound` exception — the object was never// PUT (a never-completed upload). Match on the error name / 404 status rather than// importing the class, so an unrelated failure (network, auth) still throws.const isMissingObject = (e: unknown): boolean => { if (typeof e !== 'object' || e === null) { return false; } const name = (e as { name?: unknown }).name; const status = (e as { $metadata?: { httpStatusCode?: unknown } }).$metadata ?.httpStatusCode; return name === 'NotFound' || name === 'NoSuchKey' || status === 404;};
// The second half of the two-step write: HEAD the object the browser just PUT, then// insert the row from server-observed identity — never the client's claim. byteSize// and contentType come off the HeadObjectCommand (R2 does not enforce the signed// ContentLength, so the HEAD is the real boundary); a missing object means the upload// never landed → object-not-found. The unique(objectKey) constraint is the second// defense layer: a replayed finalize trips 23505 → conflict, never a duplicate row.//// No row exists before this point — a never-completed upload leaves only an orphan// object (cheap, lifecycle-swept), never an orphan row that would lie in the UI.export const finalizeUpload = authedAction( 'member', z.strictObject({ uploadId: z.uuid(), objectKey: z.string().min(1), originalFileName: z.string().min(1).max(255), contentType: z.enum(ALLOWED_CONTENT_TYPES), }), async (input, ctx): Promise<Result<{ fileId: string }>> => { let head: HeadObjectCommandOutput; try { head = await r2.send( new HeadObjectCommand({ Bucket: BUCKET, Key: input.objectKey }), ); } catch (e) { if (isMissingObject(e)) { return UploadError.toResult( new UploadError('object-not-found', 'The upload did not complete.'), ); } throw e; }
if (head.ContentType !== input.contentType) { return UploadError.toResult( new UploadError( 'size-mismatch', 'The uploaded file did not match what was signed.', ), ); }
const byteSize = head.ContentLength ?? 0; if (byteSize > MAX_BYTES) { return UploadError.toResult( new UploadError('size-mismatch', 'The uploaded file is too large.'), ); }
try { await tenantDb(ctx.orgId).transaction(async (tx) => { await tx.insert(fileMetadata).values({ id: input.uploadId, organizationId: ctx.orgId, uploadedBy: ctx.user.id, objectKey: input.objectKey, originalFileName: input.originalFileName, contentType: head.ContentType ?? input.contentType, byteSize, });
await logAudit(tx, { action: 'file.uploaded', subjectType: 'file', subjectId: input.uploadId, payload: { byteSize, contentType: head.ContentType }, }); }); } catch (e) { if (isUniqueViolation(e)) { return err('conflict', 'This file has already been finalized.'); } throw e; }
return ok({ fileId: input.uploadId }); },);UploadForm — XHR PUT with a progress bar
Section titled “UploadForm — XHR PUT with a progress bar”The browser side runs the same three beats: sign, PUT straight to R2 with progress, finalize. The PUT goes over XMLHttpRequest, the only browser API that reports upload progress.
const ALLOWED_CLIENT_TYPES = [ 'image/png', 'image/jpeg', 'image/webp', 'application/pdf', 'text/csv',] as const;const MAX_BYTES = 25 * 1024 * 1024;
const ACCEPT = ALLOWED_CLIENT_TYPES.join(',');
const putToR2 = ( url: string, file: File, onProgress: (percent: number) => void,): Promise<void> => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { onProgress(Math.round((event.loaded / event.total) * 100)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error(`Upload failed (${xhr.status}).`)); } }; xhr.onerror = () => reject(new Error('Upload failed.')); xhr.send(file); });
export const UploadForm = () => { const router = useRouter(); const fileInputRef = useRef<HTMLInputElement>(null); const [status, setStatus] = useState<UploadStatus>('idle'); const [progress, setProgress] = useState(0); const [error, setError] = useState<string | null>(null);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setError(null);
const input = fileInputRef.current; const file = input?.files?.[0]; if (!input || !file) { setError('Pick a file to upload.'); return; } if (!isAllowedType(file.type)) { setError('That file type is not supported.'); return; } if (file.size > MAX_BYTES) { setError('That file is larger than the 25 MB limit.'); return; }
setProgress(0); setStatus('signing');
const signFd = new FormData(); signFd.set('fileName', file.name); signFd.set('contentType', file.type); signFd.set('claimedSize', String(file.size)); const signed = await presignedPut(null, signFd); if (!signed.ok) { setStatus('failed'); setError(signed.error.userMessage); return; }
setStatus('uploading'); try { await putToR2(signed.data.url, file, setProgress); } catch (e) { setStatus('failed'); setError(e instanceof Error ? e.message : 'Upload failed.'); return; }
setStatus('finalizing'); const finalizeFd = new FormData(); finalizeFd.set('uploadId', signed.data.uploadId); finalizeFd.set('objectKey', signed.data.objectKey); finalizeFd.set('originalFileName', file.name); finalizeFd.set('contentType', file.type); const finalized = await finalizeUpload(null, finalizeFd); if (!finalized.ok) { setStatus('failed'); setError(finalized.error.userMessage); return; }
setStatus('done'); input.value = ''; router.refresh(); };The client mirrors the server allowlist and cap as plain constants. It can’t import them from lib/r2.ts — that module carries server-only, the poison pill, so importing it into a Client Component is a build error. These copies drive instant pre-checks for feedback only; the action re-validates against the real ones.
const ALLOWED_CLIENT_TYPES = [ 'image/png', 'image/jpeg', 'image/webp', 'application/pdf', 'text/csv',] as const;const MAX_BYTES = 25 * 1024 * 1024;
const ACCEPT = ALLOWED_CLIENT_TYPES.join(',');
const putToR2 = ( url: string, file: File, onProgress: (percent: number) => void,): Promise<void> => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { onProgress(Math.round((event.loaded / event.total) * 100)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error(`Upload failed (${xhr.status}).`)); } }; xhr.onerror = () => reject(new Error('Upload failed.')); xhr.send(file); });
export const UploadForm = () => { const router = useRouter(); const fileInputRef = useRef<HTMLInputElement>(null); const [status, setStatus] = useState<UploadStatus>('idle'); const [progress, setProgress] = useState(0); const [error, setError] = useState<string | null>(null);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setError(null);
const input = fileInputRef.current; const file = input?.files?.[0]; if (!input || !file) { setError('Pick a file to upload.'); return; } if (!isAllowedType(file.type)) { setError('That file type is not supported.'); return; } if (file.size > MAX_BYTES) { setError('That file is larger than the 25 MB limit.'); return; }
setProgress(0); setStatus('signing');
const signFd = new FormData(); signFd.set('fileName', file.name); signFd.set('contentType', file.type); signFd.set('claimedSize', String(file.size)); const signed = await presignedPut(null, signFd); if (!signed.ok) { setStatus('failed'); setError(signed.error.userMessage); return; }
setStatus('uploading'); try { await putToR2(signed.data.url, file, setProgress); } catch (e) { setStatus('failed'); setError(e instanceof Error ? e.message : 'Upload failed.'); return; }
setStatus('finalizing'); const finalizeFd = new FormData(); finalizeFd.set('uploadId', signed.data.uploadId); finalizeFd.set('objectKey', signed.data.objectKey); finalizeFd.set('originalFileName', file.name); finalizeFd.set('contentType', file.type); const finalized = await finalizeUpload(null, finalizeFd); if (!finalized.ok) { setStatus('failed'); setError(finalized.error.userMessage); return; }
setStatus('done'); input.value = ''; router.refresh(); };putToR2 wraps an XMLHttpRequest PUT in a promise. setRequestHeader('Content-Type', file.type) sends the exact type that was signed. xhr.upload.onprogress fires as bytes leave, and event.loaded / event.total is the percentage that drives the bar. A 2xx resolves; anything else rejects, so a 4xx from R2 surfaces as a real error you can show.
const ALLOWED_CLIENT_TYPES = [ 'image/png', 'image/jpeg', 'image/webp', 'application/pdf', 'text/csv',] as const;const MAX_BYTES = 25 * 1024 * 1024;
const ACCEPT = ALLOWED_CLIENT_TYPES.join(',');
const putToR2 = ( url: string, file: File, onProgress: (percent: number) => void,): Promise<void> => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { onProgress(Math.round((event.loaded / event.total) * 100)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error(`Upload failed (${xhr.status}).`)); } }; xhr.onerror = () => reject(new Error('Upload failed.')); xhr.send(file); });
export const UploadForm = () => { const router = useRouter(); const fileInputRef = useRef<HTMLInputElement>(null); const [status, setStatus] = useState<UploadStatus>('idle'); const [progress, setProgress] = useState(0); const [error, setError] = useState<string | null>(null);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setError(null);
const input = fileInputRef.current; const file = input?.files?.[0]; if (!input || !file) { setError('Pick a file to upload.'); return; } if (!isAllowedType(file.type)) { setError('That file type is not supported.'); return; } if (file.size > MAX_BYTES) { setError('That file is larger than the 25 MB limit.'); return; }
setProgress(0); setStatus('signing');
const signFd = new FormData(); signFd.set('fileName', file.name); signFd.set('contentType', file.type); signFd.set('claimedSize', String(file.size)); const signed = await presignedPut(null, signFd); if (!signed.ok) { setStatus('failed'); setError(signed.error.userMessage); return; }
setStatus('uploading'); try { await putToR2(signed.data.url, file, setProgress); } catch (e) { setStatus('failed'); setError(e instanceof Error ? e.message : 'Upload failed.'); return; }
setStatus('finalizing'); const finalizeFd = new FormData(); finalizeFd.set('uploadId', signed.data.uploadId); finalizeFd.set('objectKey', signed.data.objectKey); finalizeFd.set('originalFileName', file.name); finalizeFd.set('contentType', file.type); const finalized = await finalizeUpload(null, finalizeFd); if (!finalized.ok) { setStatus('failed'); setError(finalized.error.userMessage); return; }
setStatus('done'); input.value = ''; router.refresh(); };Pre-checks run before any network call: a file is present, its type is on the allowlist, its size is under the cap. Defense-in-depth — a 50 MB drop is rejected here with no round trip — but not the boundary. The action re-validates and the HEAD reads the true size regardless.
const ALLOWED_CLIENT_TYPES = [ 'image/png', 'image/jpeg', 'image/webp', 'application/pdf', 'text/csv',] as const;const MAX_BYTES = 25 * 1024 * 1024;
const ACCEPT = ALLOWED_CLIENT_TYPES.join(',');
const putToR2 = ( url: string, file: File, onProgress: (percent: number) => void,): Promise<void> => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { onProgress(Math.round((event.loaded / event.total) * 100)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error(`Upload failed (${xhr.status}).`)); } }; xhr.onerror = () => reject(new Error('Upload failed.')); xhr.send(file); });
export const UploadForm = () => { const router = useRouter(); const fileInputRef = useRef<HTMLInputElement>(null); const [status, setStatus] = useState<UploadStatus>('idle'); const [progress, setProgress] = useState(0); const [error, setError] = useState<string | null>(null);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setError(null);
const input = fileInputRef.current; const file = input?.files?.[0]; if (!input || !file) { setError('Pick a file to upload.'); return; } if (!isAllowedType(file.type)) { setError('That file type is not supported.'); return; } if (file.size > MAX_BYTES) { setError('That file is larger than the 25 MB limit.'); return; }
setProgress(0); setStatus('signing');
const signFd = new FormData(); signFd.set('fileName', file.name); signFd.set('contentType', file.type); signFd.set('claimedSize', String(file.size)); const signed = await presignedPut(null, signFd); if (!signed.ok) { setStatus('failed'); setError(signed.error.userMessage); return; }
setStatus('uploading'); try { await putToR2(signed.data.url, file, setProgress); } catch (e) { setStatus('failed'); setError(e instanceof Error ? e.message : 'Upload failed.'); return; }
setStatus('finalizing'); const finalizeFd = new FormData(); finalizeFd.set('uploadId', signed.data.uploadId); finalizeFd.set('objectKey', signed.data.objectKey); finalizeFd.set('originalFileName', file.name); finalizeFd.set('contentType', file.type); const finalized = await finalizeUpload(null, finalizeFd); if (!finalized.ok) { setStatus('failed'); setError(finalized.error.userMessage); return; }
setStatus('done'); input.value = ''; router.refresh(); };Beat one: build a FormData and call presignedPut. The null first argument is the useActionState shape authedAction exposes (the wrapper from the roles work), called directly here rather than through a form’s action prop. A non-ok Result flips the status to failed and surfaces error.userMessage.
const ALLOWED_CLIENT_TYPES = [ 'image/png', 'image/jpeg', 'image/webp', 'application/pdf', 'text/csv',] as const;const MAX_BYTES = 25 * 1024 * 1024;
const ACCEPT = ALLOWED_CLIENT_TYPES.join(',');
const putToR2 = ( url: string, file: File, onProgress: (percent: number) => void,): Promise<void> => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { onProgress(Math.round((event.loaded / event.total) * 100)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error(`Upload failed (${xhr.status}).`)); } }; xhr.onerror = () => reject(new Error('Upload failed.')); xhr.send(file); });
export const UploadForm = () => { const router = useRouter(); const fileInputRef = useRef<HTMLInputElement>(null); const [status, setStatus] = useState<UploadStatus>('idle'); const [progress, setProgress] = useState(0); const [error, setError] = useState<string | null>(null);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setError(null);
const input = fileInputRef.current; const file = input?.files?.[0]; if (!input || !file) { setError('Pick a file to upload.'); return; } if (!isAllowedType(file.type)) { setError('That file type is not supported.'); return; } if (file.size > MAX_BYTES) { setError('That file is larger than the 25 MB limit.'); return; }
setProgress(0); setStatus('signing');
const signFd = new FormData(); signFd.set('fileName', file.name); signFd.set('contentType', file.type); signFd.set('claimedSize', String(file.size)); const signed = await presignedPut(null, signFd); if (!signed.ok) { setStatus('failed'); setError(signed.error.userMessage); return; }
setStatus('uploading'); try { await putToR2(signed.data.url, file, setProgress); } catch (e) { setStatus('failed'); setError(e instanceof Error ? e.message : 'Upload failed.'); return; }
setStatus('finalizing'); const finalizeFd = new FormData(); finalizeFd.set('uploadId', signed.data.uploadId); finalizeFd.set('objectKey', signed.data.objectKey); finalizeFd.set('originalFileName', file.name); finalizeFd.set('contentType', file.type); const finalized = await finalizeUpload(null, finalizeFd); if (!finalized.ok) { setStatus('failed'); setError(finalized.error.userMessage); return; }
setStatus('done'); input.value = ''; router.refresh(); };Beat two: PUT the bytes straight to R2 through putToR2, passing setProgress so the bar tracks the real transfer. This is the only step the multi-MB body touches, and it never crosses a Server Action.
const ALLOWED_CLIENT_TYPES = [ 'image/png', 'image/jpeg', 'image/webp', 'application/pdf', 'text/csv',] as const;const MAX_BYTES = 25 * 1024 * 1024;
const ACCEPT = ALLOWED_CLIENT_TYPES.join(',');
const putToR2 = ( url: string, file: File, onProgress: (percent: number) => void,): Promise<void> => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { onProgress(Math.round((event.loaded / event.total) * 100)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error(`Upload failed (${xhr.status}).`)); } }; xhr.onerror = () => reject(new Error('Upload failed.')); xhr.send(file); });
export const UploadForm = () => { const router = useRouter(); const fileInputRef = useRef<HTMLInputElement>(null); const [status, setStatus] = useState<UploadStatus>('idle'); const [progress, setProgress] = useState(0); const [error, setError] = useState<string | null>(null);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setError(null);
const input = fileInputRef.current; const file = input?.files?.[0]; if (!input || !file) { setError('Pick a file to upload.'); return; } if (!isAllowedType(file.type)) { setError('That file type is not supported.'); return; } if (file.size > MAX_BYTES) { setError('That file is larger than the 25 MB limit.'); return; }
setProgress(0); setStatus('signing');
const signFd = new FormData(); signFd.set('fileName', file.name); signFd.set('contentType', file.type); signFd.set('claimedSize', String(file.size)); const signed = await presignedPut(null, signFd); if (!signed.ok) { setStatus('failed'); setError(signed.error.userMessage); return; }
setStatus('uploading'); try { await putToR2(signed.data.url, file, setProgress); } catch (e) { setStatus('failed'); setError(e instanceof Error ? e.message : 'Upload failed.'); return; }
setStatus('finalizing'); const finalizeFd = new FormData(); finalizeFd.set('uploadId', signed.data.uploadId); finalizeFd.set('objectKey', signed.data.objectKey); finalizeFd.set('originalFileName', file.name); finalizeFd.set('contentType', file.type); const finalized = await finalizeUpload(null, finalizeFd); if (!finalized.ok) { setStatus('failed'); setError(finalized.error.userMessage); return; }
setStatus('done'); input.value = ''; router.refresh(); };Beat three: a second FormData carries the uploadId, objectKey, original file name, and content type into finalizeUpload. On success the status reaches done, the input clears, and router.refresh() re-renders the server list so the new row appears — just React state and a refresh, no client cache or store.
putToR2 wraps XMLHttpRequest in a hand-rolled promise for one reason: fetch is shorter but can’t report upload progress, so a real progress bar needs xhr.upload.onprogress. This closes the thread from the file-picker chapter, where you could read a File but not watch it leave. The header sends file.type verbatim, never a hardcoded or cleaned-up value, because the signature is bound to the content type: if a browser normalizes a .JPG to image/pjpeg, the signed and sent types diverge and R2 answers 403 SignatureDoesNotMatch.
Here is the full component with onSubmit folded away. What’s left is the imports, the status type, and the rendered form: a status machine in JSX, wired to disable every control while busy.
'use client';
import { useRouter } from 'next/navigation';import { type FormEvent, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';import { Input } from '@/components/ui/input';import { Progress } from '@/components/ui/progress';import { finalizeUpload } from '@/lib/files/finalize';import { presignedPut } from '@/lib/files/presigned-put';
type UploadStatus = | 'idle' | 'signing' | 'uploading' | 'finalizing' | 'done' | 'failed';
const ALLOWED_CLIENT_TYPES = [ 'image/png', 'image/jpeg', 'image/webp', 'application/pdf', 'text/csv',] as const;const MAX_BYTES = 25 * 1024 * 1024;
const ACCEPT = ALLOWED_CLIENT_TYPES.join(',');
const isAllowedType = ( type: string,): type is (typeof ALLOWED_CLIENT_TYPES)[number] => (ALLOWED_CLIENT_TYPES as readonly string[]).includes(type);
const putToR2 = ( url: string, file: File, onProgress: (percent: number) => void,): Promise<void> => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { onProgress(Math.round((event.loaded / event.total) * 100)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error(`Upload failed (${xhr.status}).`)); } }; xhr.onerror = () => reject(new Error('Upload failed.')); xhr.send(file); });
export const UploadForm = () => { const router = useRouter(); const fileInputRef = useRef<HTMLInputElement>(null); const [status, setStatus] = useState<UploadStatus>('idle'); const [progress, setProgress] = useState(0); const [error, setError] = useState<string | null>(null);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setError(null);
const input = fileInputRef.current; const file = input?.files?.[0]; if (!input || !file) { setError('Pick a file to upload.'); return; } if (!isAllowedType(file.type)) { setError('That file type is not supported.'); return; }59 collapsed lines
if (file.size > MAX_BYTES) { setError('That file is larger than the 25 MB limit.'); return; }
setProgress(0); setStatus('signing');
const signFd = new FormData(); signFd.set('fileName', file.name); signFd.set('contentType', file.type); signFd.set('claimedSize', String(file.size)); const signed = await presignedPut(null, signFd); if (!signed.ok) { setStatus('failed'); setError(signed.error.userMessage); return; }
setStatus('uploading'); try { await putToR2(signed.data.url, file, setProgress); } catch (e) { setStatus('failed'); setError(e instanceof Error ? e.message : 'Upload failed.'); return; }
setStatus('finalizing'); const finalizeFd = new FormData(); finalizeFd.set('uploadId', signed.data.uploadId); finalizeFd.set('objectKey', signed.data.objectKey); finalizeFd.set('originalFileName', file.name); finalizeFd.set('contentType', file.type); const finalized = await finalizeUpload(null, finalizeFd); if (!finalized.ok) { setStatus('failed'); setError(finalized.error.userMessage); return; }
setStatus('done'); input.value = ''; router.refresh(); };
const busy = status === 'signing' || status === 'uploading' || status === 'finalizing';
return ( <form data-testid="upload-form" onSubmit={onSubmit} className="flex flex-col gap-3 rounded-lg border border-input p-4" > <Input ref={fileInputRef} type="file" name="file" accept={ACCEPT} data-testid="file-input" disabled={busy} />
<div className="flex flex-col gap-2"> <Progress data-testid="upload-progress" value={progress} /> <p data-testid="upload-status" className="text-sm text-muted-foreground" > {status} </p> </div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" data-testid="upload-submit" disabled={busy}> Upload </Button> </form> );};Two things in the JSX. <Input type="file" accept={ACCEPT}> is the picker-level filter: with accept set to the joined allowlist, an .exe is greyed out in the OS file dialog, the first of the two client defenses. And every control is disabled={busy} while the flow is in flight, so a second upload can’t fire on top of the first; busy is true for exactly the three in-progress states.
page.tsx — mount the form
Section titled “page.tsx — mount the form”The page mounts UploadForm above where the list will go, with a minimal empty state standing in for the list this lesson.
import { UploadForm } from '@/app/files/upload-form';import { requireOrgUser } from '@/lib/auth';
const FilesPage = async ({ searchParams,}: { searchParams: Promise<{ cursor?: string }>;}) => { await requireOrgUser(); await searchParams;
return ( <section data-testid="files-page" className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-10" > <h1 className="text-2xl font-semibold">Files</h1>
<UploadForm />
<div data-testid="files-list" className="flex flex-col gap-2"> <p data-testid="files-empty" className="text-sm text-muted-foreground"> No files yet. </p> </div> </section> );};
export default FilesPage;requireOrgUser() gates the page on an authenticated org member and supplies the ctx the actions rely on. Keep the empty state honest, "No files yet.", because until next lesson reads the list, that stays true even right after an upload lands a row: the row exists, the page just doesn’t read it yet. Leaving the files-list wrapper in place now keeps that change small.
One last edge before you verify. If the network drops during the PUT, putToR2 rejects, the status goes to failed, and finalizeUpload never runs, so no row is written and R2 holds a partial object the lifecycle rules sweep. To recover, pick the file again: a signed URL is a one-shot capability, not a retry handle, so the whole flow re-signs from scratch.
The Result shape these actions return and the Zod-at-the-boundary validation come from Result or throw, reused here.
The Content-Type best-practice section is the exact 403 SignatureDoesNotMatch trap this lesson warns about.
The upload object whose progress event drives your bar — note it forces a CORS preflight.
The allowed-origins/methods rules behind the localhost-vs-127.0.0.1 PUT check in Moment of truth.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 3With no live bucket or session to hit, the runner reads finalizeUpload’s source instead, confirming three things the mission listed: the row is built from the HEAD’s values and ctx.user.id, never the client’s claim (req 2); an over-cap object fails at the HEAD, before any insert (req 4); and one file.uploaded entry is written in the same transaction as the row (req 6). A pass looks like this:
[32m✓[0m Lesson 3 — finalizeUpload HEADs then inserts, from server-observed truth [2m(12)[0m [32m✓[0m is implemented (no longer the "Not implemented" stub) [32m✓[0m req 2 — the row is written from HEAD-observed values, never the client claim [2m(4)[0m [32m✓[0m req 4 — an over-cap object is caught at the HEAD with size-mismatch, before any insert [2m(3)[0m [32m✓[0m req 6 — one file.uploaded audit entry, committed in the same transaction as the row [2m(4)[0m
[2mTest Files[0m [32m1 passed[0m [2m(1)[0m [2m Tests[0m [32m12 passed[0m [2m(12)[0mFour behaviors never reach a node harness: the progress bar, the byte split, the client pre-check, and the CORS preflight. With pnpm dev running and signed in as a member of a seeded org, confirm each by hand:
done with a smooth progress bar.presignedPut, an MB-scale PUT to r2.cloudflarestorage.com, and a small POST to finalizeUpload — the bytes never cross a Server Action.presignedPut call; an .exe is excluded by the file picker.127.0.0.1:3000 (not in the allowlist) makes the browser PUT fail; restoring localhost works.The file now lands in R2 and writes its row. Next lesson renders those rows as a list with working download links.