The context

The chat is part of a larger project: a control panel for sales agents with orders, shifts, and OTP signing. The chat was already working with text, images (via ImgBB upload), and voice messages (Cloudinary with resource_type=video). The only missing piece was generic document sending.

The plan was to follow the same pattern already used for voice messages: upload to Cloudinary, save the URL to Firebase Realtime Database, render the bubble on the client.

ℹ️

Starting stack: Firebase Realtime DB + Cloudinary + Netlify. No dedicated backend — everything client-side with Cloudinary unsigned upload.

The 5 implementation steps

  • 1
    CSS — document bubble

    Styles for .chat-doc-bubble: translucent blue background, file-type icon, truncated filename, download button.

  • 2
    File input + button in the + menu

    <input id="chatDocInput"> with accept for PDF, Word, Excel, PPT, TXT, CSV, ZIP, and RAR. A "📎 Document" option in the contextual menu.

  • 3
    Upload to Cloudinary with resource_type=raw

    Binary files can't go under image or video — you need raw. Same unsigned preset already used for voice messages.

  • 4
    Bubble render in _buildMsgEl()

    Dynamic icon by type (📄 PDF, 📊 Excel, 📝 Word…), filename, size in KB/MB, click to open in a new tab.

  • 5
    Chat list preview update

    DM and group previews show 📎 FileName as the last message, consistent with images and voice messages.

The key code

Upload to Cloudinary — resource_type: raw

The critical bit: Cloudinary won't accept generic binary files under the image type. You must declare resource_type=raw in the upload URL, using the same unsigned preset already configured for voice messages.

javascript
async function chatHandleDocFile(file) {
  const fd = new FormData();
  fd.append('file', file);
  fd.append('upload_preset', 'Vocali'); // existing unsigned preset

  // resource_type=raw — required for non-media files
  const res = await fetch(
    `https://api.cloudinary.com/v1_1/CLOUD_NAME/raw/upload`,
    { method: 'POST', body: fd }
  );
  const data = await res.json();
  return data.secure_url;
}

Write to Firebase — type: 'document'

The message is written to Firebase with a distinct type so the client knows how to render it. I also save fileName and fileSize to display them in the bubble without re-fetching the file.

javascript
async function _sendChatDoc(docUrl, fileName, fileSize) {
  const msgData = {
    type:     'document',
    docUrl,
    fileName,
    fileSize, // e.g. "245 KB"
    sender:   currentUser.uid,
    ts:       Date.now(),
  };
  // push to /messages/{chatId}/ — same pattern as text and images
  await db.ref(`messages/${chatId}`).push(msgData);
}

Bubble render — icon by file type

In _buildMsgEl() I add a branch for type === 'document'. The icon changes based on the extension, so the user can immediately see what kind of file they're receiving.

javascript
function getDocIcon(fileName) {
  const ext = fileName.split('.').pop().toLowerCase();
  const icons = {
    pdf: '📄', doc: '📝', docx: '📝',
    xls: '📊', xlsx: '📊',
    ppt: '📑', pptx: '📑',
    zip: '🗜️', rar: '🗜️', '7z': '🗜️',
    txt: '📃', csv: '📃',
  };
  return icons[ext] ?? '📎';
}

Problems I ran into

1. JS syntax: extra bracket in the ternary

After modifying _buildMsgEl() to add the document branch next to the existing image one, the file wouldn't pass JS validation. The culprit was a nested ternary with one closing bracket too many:

javascript — before (error)
type === 'image' ? renderImg() :
type === 'document' ? renderDoc()) : // ← extra )
renderText()
javascript — after (fixed)
type === 'image' ? renderImg() :
type === 'document' ? renderDoc() :
renderText()

2. str_replace fails on identical strings

When using an auto-replace tool on a large file, if two code blocks are nearly identical (e.g. two almost-identical consecutive branches), the replace can't tell them apart and fails silently. The fix: use Python with line-index-based substitution instead of text matching.

⚠️

Watch out with large files: if you use str_replace on JS files with 1000+ lines and repeated patterns, the substitution may hit the wrong line. Always include enough surrounding context in the search pattern.

3. The Cloudinary preset must support resource_type raw

The Vocali preset had originally been created for audio only. On Cloudinary, an unsigned preset needs to have resource_type set to auto (or you can create a dedicated raw preset) — otherwise uploading a PDF returns a 400 error. I fixed it by switching the existing preset from audio to auto in the Cloudinary dashboard.

When Cloudinary makes sense for document upload (and when it doesn't)

When to use it

  • Chat or app already on Cloudinary for other media (images, voice): extending to resource_type=raw reuses the same preset and infrastructure, with no second service to introduce.
  • No dedicated backend desired: direct unsigned upload from client to Cloudinary avoids writing a server function just to handle files.
  • Moderate document volume: Cloudinary's free plan comfortably covers non-massive use.

When not to use it

  • Sensitive documents with granular access control needs: a public unsigned upload doesn't offer the same control as a private bucket with dedicated access rules (e.g. Firebase Storage with security rules).
  • Very high volumes: past a certain threshold, Cloudinary costs exceed those of a cheaper storage solution like Firebase Storage or S3.

Alternatives

For those already in the Firebase ecosystem who prefer keeping everything on one provider, Firebase Storage with dedicated security rules is a direct alternative, at the cost of a slightly more complex setup than Cloudinary's unsigned upload.

Common mistakes

  • Forgetting resource_type=raw: without this explicit parameter, Cloudinary rejects or mishandles generic binary files like PDF and Word.
  • Not saving file name and size at upload time: retrieving them later requires extra API calls every time the message is displayed.
  • Silent syntax errors in nested ternaries: one extra character can produce syntactically valid code with wrong logic, with no visible build error — runtime behavior should always be manually tested after changes to complex conditional branches.

The final result

With this implementation the chat now supports four message types: text, image, voice, and document. The Firebase data structure is uniform — every message always has type, sender, and ts as base fields, with type-specific fields layered on top.

  • Upload: Cloudinary CDN with resource_type=raw
  • Supported types: PDF, DOC/X, XLS/X, PPT/X, TXT, CSV, ZIP, RAR, 7z
  • Visual feedback: emoji icon by type, truncated filename, size, download arrow
  • List preview: 📎 FileName in the last-message row of the chat list
  • Push notification: included, with text "Sent a document"
💡

Reusability: the entire pattern (CDN upload → Firebase → bubble render) is identical for all media types. Adding a fifth type — e.g. video — would only require a new branch in the render and the appropriate resource_type on Cloudinary.


Takeaway

Adding document upload to a Firebase chat isn't hard, but three things are worth keeping in mind from the start:

  1. Cloudinary resource_type=raw is mandatory for non-media files. It won't work under image or video.
  2. Save fileName and fileSize to Firebase at upload time — you can't retrieve them from the Cloudinary URL later without an extra API call.
  3. Test JS validation after every change to large files with nested ternaries. An extra bracket doesn't throw a visible build error, but it silently breaks runtime behavior.

Frequently asked questions

Why does Cloudinary need resource_type=raw for documents instead of image or video?

Cloudinary treats generic binary files (PDF, Word, Excel, ZIP) as a separate type from images and videos. Without declaring resource_type=raw in the upload URL, the file isn't accepted correctly — you also need an unsigned preset with resource_type set to auto on the Cloudinary dashboard.

Why is it better to save fileName and fileSize to Firebase at upload time instead of fetching them later?

The Cloudinary URL alone doesn't carry this information: retrieving it later would require an extra API call every time the message is shown. Saving it immediately in the same message object lets the document bubble render instantly with no additional requests.

Why doesn't an extra closing parenthesis in a nested ternary always produce a visible build error?

A syntax issue in a nested ternary can still produce JavaScript that's syntactically valid but with different logic than intended, so it doesn't always block the build — it breaks runtime behavior instead. That's why it's important to manually test JS validation after every change to similar conditional branches, especially in large files with repeated patterns.

← Back to Blog