Skip to content
scroll to zoom · drag to pan

Documents, audio and everything else

Not everything you upload is a photo. Four paths, decided by MIME type, and the last one is the one people assume wrongly.

MIME path what you get
image/* ladder WebP variants + palette + dimensions
video/* background job poster, MP4, HLS, optional AI proxy
audio/* inline transcode an MP3 variant plus the untouched original
anything else passthrough the bytes, stored and served as-is

Voice notes, clips, tracks. Two things are written:

  1. The original, untouched, under the original variant.
  2. An mp3 variant — mono, ~96 kbps, tuned for speech.

The transcode exists because of a real interoperability problem: MediaRecorder emits audio/webm;opus on Chrome/Android and audio/mp4/AAC on iOS, and each one chokes somewhere the other works. MP3 is the one container and codec every target browser decodes.

// Give raw bytes an explicit audio MIME, or they classify as `other`
// and you get no mp3 variant.
await nt.upload(bytes, {
contentType: "audio/mpeg",
fileName: "voice-note.mp3",
});

Legacy .m4a/.wav with no matching pipeline still fall back to original, so the bytes are safe either way.

PDFs, spreadsheets, zips — the passthrough path

Section titled “PDFs, spreadsheets, zips — the passthrough path”

A PDF, an .xlsx, a .csv, a .zip, a font, a .glb — anything that is not image, video or audio takes the same branch:

  • stored raw, byte-for-byte, under the original variant key
  • served from the CDN with its real content type
  • the row is ready immediately — there is no job to wait for
  • classified as kind: "document" (PDF) or kind: "other" (everything else)
const res = await nt.upload(pdfFile, { fileName: "contract.pdf" });
// → { ok: true, passthrough: true, assetId, url }

What you do NOT get, and should not wait for

Section titled “What you do NOT get, and should not wait for”

This is the part worth being blunt about:

  • No thumbnail. No first-page render of a PDF, no spreadsheet preview.
  • No text extraction. No search, no OCR, no page count.
  • No transforms. A /t/ URL on a document is meaningless.
  • No virus scanning. The bytes are stored as received.

If you need any of that, you need a document pipeline, and this is not one. What this is good at is being the single addressed, deduplicated, CDN-backed place your files live — so a contract and the photos attached to it are the same kind of thing to your code.

Getting the extension right matters more here

Section titled “Getting the extension right matters more here”

For images the stored extension rarely bites. For documents it always does, because original is the only variant there is, and its extension comes from the uploaded filename, not the MIME:

// Right: the URL will end in .pdf
await nt.upload(bytes, { fileName: "contract.pdf", contentType: "application/pdf" });
// Wrong: the URL ends in .bin and the browser will not know what to do with it
await nt.upload(bytes);

A useful trick, since video presets silently drop original: upload it under a non-video filename and it takes the passthrough branch instead.

// Stored verbatim, no transcode, no HLS — a real backup of the source file.
await nt.upload(clipBytes, { fileName: "master.mp4.bin" });

You lose poster, playback variant and streaming — which is the point. Do this in addition to a normal video upload, not instead of it.

Every path is content-addressed. Uploading the same PDF twice returns the same asset, and the second upload transfers nothing. That is also true across different filenames — the SHA is of the bytes, not the name.