Scientyfic World

Firebase Storage and Firestore in React: Upload Files and Track Them

Cloud Storage for Firebase holds files such as images and PDFs; Firestore holds structured data you can query. Most apps need both: upload a file to Storage, then keep a...

Share:

Get an AI summary of this article

How to fetch data from Firebase storage to Firestore blog banner

Cloud Storage for Firebase holds files such as images and PDFs; Firestore holds structured data you can query. Most apps need both: upload a file to Storage, then keep a record of it in Firestore so you can list, search and secure it. This guide builds that pattern in React with the modular Firebase SDK, plus a Cloud Function that writes the Firestore record automatically whenever a file is uploaded.

What I tested: everything below ran in the Firebase Local Emulator Suite (Auth, Storage, Firestore and Functions) with Firebase JS SDK 13.0, firebase-functions 7.4 and firebase-admin 14.5. One end-to-end test uploaded an image, waited for the Cloud Function to write its record, listed it, downloaded it through its URL and deleted it. A second test confirmed the Security Rules block uploads to another user’s folder, non-image files, client-created records and reading another user’s records. The code also type-checks.

The 2023 version of this post used the old namespaced SDK (firebase.storage().ref()) and a storageRef.on('put', ...) “trigger” that does not exist in any Firebase SDK. Real upload triggers run in Cloud Functions, which is what this version uses.

Before You Start: Billing

Cloud Storage for Firebase now requires the pay-as-you-go Blaze plan. According to Firebase’s storage FAQ, the requirement took effect for existing default buckets on February 3, 2026, and Spark-plan projects lost access to their buckets. Blaze still has a no-cost usage tier, and new default buckets are named PROJECT_ID.firebasestorage.app instead of PROJECT_ID.appspot.com. Cloud Functions also require Blaze. You can follow this whole tutorial for free in the local emulators.

How It Works

  1. The signed-in user uploads a file from React to users/{uid}/{fileName} in Storage.
  2. Storage Security Rules check that the user owns the folder, the file is an image and it is under 10 MB.
  3. When the upload finishes, a Cloud Function triggered by onObjectFinalized writes a record to the Firestore files collection.
  4. React listens to the user’s records in Firestore and shows the list live.
  5. To display or download a file, the app asks Storage for a download URL on demand.

Why let a function write the record instead of the browser? The record then always matches what actually landed in Storage, the client cannot forge records for files it never uploaded, and uploads from other sources (scripts, the console) get records too. If you do not want Cloud Functions, the client can write the record after the upload completes; just tighten the Firestore rules so users can only create records for their own paths.

Step 1: Initialize Firebase

npm install firebase
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';

const app = initializeApp({
  apiKey: 'your-api-key',
  authDomain: 'your-project.firebaseapp.com',
  projectId: 'your-project',
  storageBucket: 'your-project.firebasestorage.app',
  appId: 'your-app-id',
});

export const auth = getAuth(app);
export const db = getFirestore(app);
export const storage = getStorage(app);

Use your own values from Project settings > Your apps. Users need to be signed in for the rules below; see Firebase Authentication with React for that part.

Step 2: Security Rules

Storage rules: each user can only read, write and delete files in their own folder, and only images under 10 MB can be uploaded.

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    // Each user can read and write only their own folder.
    match /users/{uid}/{fileName} {
      allow read: if request.auth != null && request.auth.uid == uid;
      allow write: if request.auth != null && request.auth.uid == uid
                   && request.resource.size < 10 * 1024 * 1024
                   && request.resource.contentType.matches('image/.*');
      allow delete: if request.auth != null && request.auth.uid == uid;
    }
  }
}

Firestore rules: users can read and delete their own file records, but only the server creates them. The Cloud Function uses the Admin SDK, which bypasses rules.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /files/{fileId} {
      // Owners can read and delete their file records; only the server
      // (Cloud Function with admin access) creates them.
      allow read, delete: if request.auth != null && resource.data.owner == request.auth.uid;
      allow create, update: if false;
    }
  }
}

The query in Step 4 filters by owner and sorts by createdAt, which needs a composite index in production (the emulator does not enforce indexes). Deploy it with this firestore.indexes.json:

{
  "indexes": [
    {
      "collectionGroup": "files",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "owner", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "DESCENDING" }
      ]
    }
  ]
}

Step 3: The Cloud Function

In your functions folder, install firebase-functions and firebase-admin, then add this function. It runs after every completed upload to the default bucket, ignores files outside users/, and writes the record with an ID derived from the path, so retries and overwrites update the same document instead of creating duplicates:

import { onObjectFinalized } from 'firebase-functions/v2/storage';
import { initializeApp } from 'firebase-admin/app';
import { FieldValue, getFirestore } from 'firebase-admin/firestore';

initializeApp();

// Runs after every successful upload to the default bucket and records the
// file in Firestore. Doc IDs are derived from the path, so a retry or an
// overwrite of the same file updates the same record instead of duplicating it.
export const recordUpload = onObjectFinalized(async event => {
  const { name: path, contentType, size } = event.data;
  const match = path?.match(/^users\/([^/]+)\/(.+)$/);
  if (!match) return; // ignore files outside users/{uid}/

  const [, owner, name] = match;
  const id = path.replaceAll('/', '__');
  await getFirestore().collection('files').doc(id).set({
    owner,
    path,
    name,
    contentType: contentType ?? 'application/octet-stream',
    size: Number(size),
    createdAt: FieldValue.serverTimestamp(),
  });
});

Two things to keep in mind from Firebase’s storage triggers guide: deploy the function in a location compatible with your bucket’s region, and if a function ever writes back to the same bucket (for example, thumbnails), make it skip its own output so it does not trigger itself in a loop.

Step 4: Upload, List, View and Delete From React

These helpers wrap the Storage and Firestore calls. uploadBytesResumable reports progress; onSnapshot keeps the list live:

import { type Auth } from 'firebase/auth';
import {
  collection, deleteDoc, doc, onSnapshot, orderBy, query, where, type Firestore,
} from 'firebase/firestore';
import { deleteObject, getDownloadURL, ref, uploadBytesResumable, type FirebaseStorage } from 'firebase/storage';

export type FileRecord = {
  id: string;
  owner: string;
  path: string;
  name: string;
  contentType: string;
  size: number;
  createdAt: unknown;
};

// Upload to users/{uid}/{name}. The Cloud Function writes the Firestore record.
export function uploadFile(
  storage: FirebaseStorage,
  auth: Auth,
  file: Blob,
  name: string,
  onProgress?: (percent: number) => void,
): Promise<string> {
  const uid = auth.currentUser?.uid;
  if (!uid) return Promise.reject(new Error('Sign in first'));
  const task = uploadBytesResumable(ref(storage, `users/${uid}/${name}`), file, {
    contentType: file.type || 'application/octet-stream',
  });
  return new Promise((resolve, reject) => {
    task.on(
      'state_changed',
      snap => onProgress?.(Math.round((snap.bytesTransferred / snap.totalBytes) * 100)),
      reject,
      () => resolve(task.snapshot.ref.fullPath),
    );
  });
}

// Live list of the signed-in user's files, newest first.
export function watchMyFiles(db: Firestore, uid: string, onChange: (files: FileRecord[]) => void) {
  const q = query(collection(db, 'files'), where('owner', '==', uid), orderBy('createdAt', 'desc'));
  return onSnapshot(q, snap => onChange(snap.docs.map(d => ({ id: d.id, ...d.data() }) as FileRecord)));
}

// Download URLs are created on demand rather than stored, so they never go stale.
export const fileUrl = (storage: FirebaseStorage, path: string) => getDownloadURL(ref(storage, path));

export async function deleteFile(storage: FirebaseStorage, db: Firestore, record: FileRecord) {
  await deleteObject(ref(storage, record.path));
  await deleteDoc(doc(db, 'files', record.id));
}

The old version stored download URLs in Firestore. This version stores the Storage path and creates the URL when needed with getDownloadURL. A download URL contains a token that stays valid until it is revoked in the console, so anyone who has it can open the file; generating it on demand keeps the record independent of tokens and lets you revoke access without rewriting data.

A component that uses them:

import { useEffect, useState, type ChangeEvent } from 'react';
import { auth, db, storage } from './firebase';
import { deleteFile, fileUrl, uploadFile, watchMyFiles, type FileRecord } from './files';

export function MyFiles({ uid }: { uid: string }) {
  const [files, setFiles] = useState<FileRecord[]>([]);
  const [progress, setProgress] = useState<number | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => watchMyFiles(db, uid, setFiles), [uid]);

  const onPick = async (e: ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    setError(null);
    try {
      await uploadFile(storage, auth, file, file.name, setProgress);
    } catch {
      setError('Upload failed. Images up to 10 MB only.');
    } finally {
      setProgress(null);
      e.target.value = '';
    }
  };

  const open = async (f: FileRecord) => window.open(await fileUrl(storage, f.path), '_blank');

  return (
    <section>
      <h2>My images</h2>
      <input type="file" accept="image/*" onChange={onPick} disabled={progress !== null} />
      {progress !== null && <progress value={progress} max={100} />}
      {error && <p role="alert">{error}</p>}
      <ul>
        {files.map(f => (
          <li key={f.id}>
            <button onClick={() => open(f)}>{f.name}</button> ({Math.round(f.size / 1024)} KB)
            <button onClick={() => deleteFile(storage, db, f)}>Delete</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

Step 5: Test Everything in the Emulators

Add the emulators to firebase.json:

{
  "firestore": {
    "rules": "firestore.rules",
    "indexes": "firestore.indexes.json"
  },
  "storage": {
    "rules": "storage.rules"
  },
  "functions": {
    "source": "functions"
  },
  "emulators": {
    "auth": {
      "port": 9099
    },
    "firestore": {
      "port": 8080
    },
    "storage": {
      "port": 9199
    },
    "functions": {
      "port": 5001
    },
    "ui": {
      "enabled": false
    },
    "singleProjectMode": true
  }
}

This test signs up a user in the Auth emulator, uploads a small PNG, waits for the Cloud Function’s record to appear through the live query, fetches the file through its download URL, deletes it, then checks the rules:

// End-to-end against the Auth, Storage, Firestore and Functions emulators.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { initializeApp } from 'firebase/app';
import { connectAuthEmulator, createUserWithEmailAndPassword, getAuth, signOut } from 'firebase/auth';
import { connectFirestoreEmulator, getFirestore, collection, addDoc, getDocs } from 'firebase/firestore';
import { connectStorageEmulator, getStorage, ref, uploadBytes } from 'firebase/storage';
import { deleteFile, fileUrl, uploadFile, watchMyFiles, type FileRecord } from '../src/files';

const app = initializeApp({ apiKey: 'demo-key', projectId: 'demo-files', storageBucket: 'demo-files.appspot.com' });
const auth = getAuth(app);
const db = getFirestore(app);
const storage = getStorage(app);
connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true });
connectFirestoreEmulator(db, '127.0.0.1', 8080);
connectStorageEmulator(storage, '127.0.0.1', 9199);

const png = new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], { type: 'image/png' });

function waitForFiles(uid: string, count: number): Promise<FileRecord[]> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('timed out waiting for Cloud Function')), 20000);
    const stop = watchMyFiles(db, uid, files => {
      if (files.length === count) { clearTimeout(timer); stop(); resolve(files); }
    });
  });
}

test('upload -> function writes Firestore record -> list, URL, delete', async () => {
  const { user } = await createUserWithEmailAndPassword(auth, `u${Date.now()}@example.com`, 'secret123');
  const progress: number[] = [];
  const path = await uploadFile(storage, auth, png, 'avatar.png', p => progress.push(p));
  assert.equal(path, `users/${user.uid}/avatar.png`);
  assert.equal(progress.at(-1), 100);

  const [record] = await waitForFiles(user.uid, 1);
  assert.equal(record.name, 'avatar.png');
  assert.equal(record.contentType, 'image/png');
  assert.equal(record.size, 8);

  const url = await fileUrl(storage, record.path);
  const res = await fetch(url);
  assert.equal(res.status, 200);
  assert.equal((await res.arrayBuffer()).byteLength, 8);

  await deleteFile(storage, db, record);
  await waitForFiles(user.uid, 0);
  await signOut(auth);
});

test('rules: no foreign uploads, no non-images, no client-created records', async () => {
  const { user } = await createUserWithEmailAndPassword(auth, `v${Date.now()}@example.com`, 'secret123');
  await assert.rejects(uploadBytes(ref(storage, 'users/someone-else/x.png'), png, { contentType: 'image/png' }));
  await assert.rejects(uploadBytes(ref(storage, `users/${user.uid}/notes.txt`), new Blob(['hi'], { type: 'text/plain' }), { contentType: 'text/plain' }));
  await assert.rejects(addDoc(collection(db, 'files'), { owner: user.uid, path: 'fake' }));
  // Listing another user's records is denied too.
  const { where, query } = await import('firebase/firestore');
  await assert.rejects(getDocs(query(collection(db, 'files'), where('owner', '==', 'someone-else'))));
  await signOut(auth);
});
$ npx firebase emulators:exec --project demo-files "npx tsx --test --test-concurrency=1 test/flow.test.ts"
✔  functions[us-central1-recordUpload]: storage function initialized.
i  functions: Finished "us-central1-recordUpload" in 112ms
✔ upload -> function writes Firestore record -> list, URL, delete
✔ rules: no foreign uploads, no non-images, no client-created records
ℹ pass 2

The “permission denied” messages Firestore logs during the second test are expected: they are the blocked writes the test is checking for.

Going Further

  • Add an onObjectDeleted function to remove records when files are deleted outside your app.
  • Generate thumbnails in the upload function (skipping files that are already thumbnails).
  • Use getMetadata to show size and type without downloading a file.
  • For more on querying and writing Firestore data, see how to use Cloud Firestore in a React app.

Conclusion

The reliable way to connect Storage and Firestore is: upload files to per-user paths protected by Storage rules, let a Cloud Function record each upload in Firestore, read the records with a live query, and create download URLs only when you need them. Test the whole loop, including your rules, in the Emulator Suite before deploying.

FAQs

Can a Firestore document store a file directly?

Not practically. Firestore documents are limited to 1 MiB and are meant for structured data. Store the file in Cloud Storage and save its path and metadata in Firestore.

Should I save the download URL in Firestore?

You can, but storing the Storage path and calling getDownloadURL when needed is more flexible: URLs contain access tokens that can be revoked, and paths do not change.

Is there a Storage upload trigger in the client SDK?

No. Use the upload task’s progress and completion callbacks in the client, or a Cloud Functions onObjectFinalized trigger on the server, which runs for every completed upload.

Do I need the Blaze plan?

Yes for Cloud Storage for Firebase and Cloud Functions on a real project. Blaze includes a no-cost tier, and you can develop entirely in the local emulators without billing.

How do I restrict uploads to images under a size limit?

Check request.resource.size and request.resource.contentType in your Storage Security Rules, as in this guide. Validate in the UI too, but the rules are what actually enforce it.

How do I test Storage rules and functions locally?

Run the Firebase Local Emulator Suite with the Auth, Storage, Firestore and Functions emulators, connect the SDK to them, and run your tests with firebase emulators:exec.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts