Acme Store Docs
Features

Media & Image Uploads

Upload product images to Firebase Storage from the admin, enforced by storage rules.

Product images are uploaded to Firebase Storage straight from the admin product form. Uploads are validated in the browser, enforced server-side by storage.rules, and served publicly so the storefront catalog can render them. A media library page lets you browse, reuse, and delete what you have uploaded.

This is the only piece of the quote-based boilerplate that uses Firebase Storage. If you never upload files — for example, if you paste image URLs from an external CDN — you can skip the Storage setup entirely.

Firebase Storage is only needed for uploading images. The product form also accepts a pasted image URL and a "Choose from library" picker, so an external host works without Storage configured at all.

How image uploads work

Every product has an optional primaryImageURL field. In the admin product form (/admin/products/new and the edit page) that field is backed by the ImageUploadField component, which offers three ways to set it:

Upload a file — validated client-side, then uploaded to Firebase Storage with a live progress percentage. The resulting download URL is written into primaryImageURL.
Choose from library — opens a dialog listing every previously uploaded image; picking one reuses its existing URL (no re-upload).
Paste an image URL — a plain text field for any external URL (a CDN, a stock-photo host, etc.). No Storage involved.

Where files are stored

Uploads land under a per-product folder, with a timestamp prefix to avoid name collisions:

Storage path shape
products/{productId}/{timestamp}-{sanitizedFileName}

The productId is the product's SKU / document id. While you are creating a new product the SKU may still be blank, so those uploads land under products/unassigned/ until you save. The file name is sanitized to keep only URL-safe characters ([a-zA-Z0-9._-], everything else becomes _).

The upload logic lives in src/lib/firebase/storageService.ts:

src/lib/firebase/storageService.ts
export function uploadProductImage(
  file: File,
  productId: string,
  onProgress?: (pct: number) => void
): Promise<string> {
  // ...bucket-not-configured guard...
  const path = `products/${productId}/${Date.now()}-${sanitizeFileName(file.name)}`;
  const storageRef = ref(getStorageClient(), path);
  const task = uploadBytesResumable(storageRef, file, {
    contentType: file.type,
  });
  // resolves to getDownloadURL(...) on completion
}

The returned download URL is a firebasestorage.googleapis.com link that includes an access token, and it is what gets saved as the product's primaryImageURL in the products collection.

Client-side validation

Before any bytes are sent, validateImageFile in storageService.ts checks the file. These rules are mirrored exactly in storage.rules so a bypassed client cannot sneak anything past the server.

CheckAllowedError message
MIME typeimage/jpeg, image/png, image/webp, image/avifOnly JPEG, PNG, WebP or AVIF images are allowed.
Size≤ 5 MB (5 * 1024 * 1024 bytes)The image must be 5MB or smaller.

The file <input> is also restricted with accept="image/jpeg,image/png,image/webp,image/avif".

The client validator accepts four specific image types, while storage.rules accepts any image/* content type up to 5 MB (see below). The client list is the stricter of the two.

Storage rules

storage.rules (deployed with firebase deploy --only storage) governs what may be read and written. It reuses the same admins collection as firestore.rules as the source of truth for who is an admin.

storage.rules
service firebase.storage {
  match /b/{bucket}/o {

    function signedIn() {
      return request.auth != null;
    }

    function isAdmin() {
      return signedIn() &&
        firestore.exists(/databases/(default)/documents/admins/$(request.auth.uid));
    }

    function isValidImage() {
      return request.resource.size <= 5 * 1024 * 1024 &&
        request.resource.contentType.matches('image/.*');
    }

    // Product images uploaded from the admin product form.
    match /products/{productId}/{fileName} {
      allow read: if true;
      allow write: if isAdmin() && isValidImage();
    }

    // Everything else is off-limits.
    match /{allPaths=**} {
      allow read, write: if false;
    }
  }
}

What this means in practice:

  • Public read on products/{productId}/{fileName} — anyone can load a product image URL, which is required for the storefront catalog to render.
  • Admin-only write — only a signed-in user whose UID exists in the Firestore admins collection can upload or delete, and only when the file is an image ≤ 5 MB. Cross-service Firestore lookups from Storage rules are a v2 feature.
  • Everything else is denied — any path outside products/ has read, write: if false. There is no general-purpose file storage here.

isValidImage() enforces contentType.matches('image/.*') and a 5 MB cap, but it does not restrict the four specific formats the client validator checks. Server-side, any image/* type up to 5 MB is accepted.

See admin access & security rules for how the admins collection is populated, and Firebase setup for deploying the rules.

The media library

The admin Media library page (/admin/media) lists every uploaded product image and lets you reuse or delete it. It is powered by MediaGrid, the same component the product form's "Choose from library" dialog uses.

What it shows

listProductImages() enumerates the one-level products/{productId}/ folders, resolves each file's download URL and metadata in parallel, and returns them newest-first (by last-updated timestamp). To bound network fan-out it lists at most 500 files.

Each card shows the product id, file name, and human-readable size (KB/MB), plus two actions:

ActionWhat it does
Copy URLCopies the file's public download URL to the clipboard.
Delete (trash icon)Opens a confirmation dialog, then permanently removes the file from Storage via deleteProductImage(path).

Deletion does not update products

Deleting a file in the media library removes it from Storage only. It does not clear the primaryImageURL (or variant imageURL) of any product that still references it — those products will show a broken image until you edit them and pick a new image. The page and delete dialog both warn about this.

Media library status

Per the project roadmap, a full media library is partly in progress: uploads to Firebase Storage from the admin form are done, and the browse / reuse / delete page described here exists, but the broader media-management experience (for example, keeping primaryImageURL references in sync on delete) is still open. Treat the delete flow accordingly.

Allowing image hosts in Next.js

Product images render through next/image in parts of the storefront, so every host you serve remote images from must be listed in images.remotePatterns in next.config.mjs. Two hosts ship preconfigured:

next.config.mjs
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "firebasestorage.googleapis.com",
      },
      {
        // Editorial/lifestyle photography used by the storefront sections.
        protocol: "https",
        hostname: "picsum.photos",
      },
    ],
  },
  // ...
};
  • firebasestorage.googleapis.com — where uploaded images live. Already allowed, so uploads work out of the box.
  • picsum.photos — placeholder photography used by the demo storefront sections.

If you paste image URLs from your own CDN or storage domain, add its hostname here or next/image will refuse to optimize it:

next.config.mjs
remotePatterns: [
  { protocol: "https", hostname: "firebasestorage.googleapis.com" },
  { protocol: "https", hostname: "picsum.photos" },
  // Add your CDN:
  { protocol: "https", hostname: "cdn.example.com" },
],

The media library grid (MediaGrid) renders thumbnails with a plain <img> tag rather than next/image, because Firebase download URLs carry access tokens and arbitrary hosts — so the grid itself needs no remotePatterns entry. The upload preview in the product form uses next/image (as does the storefront catalog), which is exactly why firebasestorage.googleapis.com ships preconfigured in remotePatterns.

Configuration

Uploading requires the Storage bucket to be set. Without it, uploads and the media library fail fast with an actionable message instead of hitting a fake bucket.

.env.example
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your-project.appspot.com

If the variable is missing, uploadProductImage and listProductImages throw:

Firebase Storage is not configured. Set NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET in .env.local.

See the full list on the environment variables page.

Troubleshooting

SymptomLikely cause
"Firebase Storage is not configured" on uploadNEXT_PUBLIC_FIREBASE_STORAGE_BUCKET is unset in .env.local.
Upload fails with a permissions errorYour signed-in UID is not in the Firestore admins collection, or storage.rules was never deployed.
"Invalid image" toastFile is not JPEG/PNG/WebP/AVIF, or larger than 5 MB.
Storefront image is broken / not optimizedThe image host is not in images.remotePatterns in next.config.mjs.
Deleted an image but a product still shows it brokenDeletion does not clear primaryImageURL — edit the product and choose a new image.

On this page