Building a Photography E-Commerce Platform: Angular 20, Firebase, Stripe & AWS S3
Development

Building a Photography E-Commerce Platform: Angular 20, Firebase, Stripe & AWS S3

Ricardo Gil
January 29, 2026
19 min read
#Angular" #"Firebase" #"Stripe" #"AWS S3" #"Photography" #"E-Commerce" #"Full-Stack" #"Payment Integration" #"Image Processing" #"Web Development

Building a Photography E-Commerce Platform: Angular 20, Firebase, Stripe & AWS S3

Introduction

What if photographers could sell individual photos directly from their portfolio, with customers making offers on prices? After building my personal photography site, I realized this "name your price" model could work brilliantly for event photographers, travel photographers, and anyone selling one-off prints.

In this post, I'll walk through building a complete photography e-commerce platform using Angular 20, Firebase for authentication and data storage, Stripe for payments, and AWS S3 for secure image storage. The result is a production-ready system that handles everything from photo uploads to secure downloads after purchase.

The Architecture

This isn't just a photo gallery—it's a complete e-commerce platform with unique features:

Customer Features:

  • Browse public photo galleries with watermarked previews
  • "Name your price" offer system (unique!)
  • Secure Stripe checkout
  • Time-limited download links for purchased originals
  • Admin Features:

  • Bulk photo upload with automatic watermarking
  • Album management
  • Offer approval/rejection workflow
  • Revenue tracking
  • Tech Stack:

    code
    Frontend:  Angular 20 (standalone components, Signals)
    Auth:      Firebase Authentication
    Database:  Firebase Realtime Database
    Storage:   AWS S3 (multi-tier: originals, watermarked, thumbnails)
    Payments:  Stripe Checkout + Payment Intents
    Backend:   Node.js API on Raspberry Pi (Nginx + PM2)

    Why This Stack?

    Angular 20 - The latest version brings Signals for reactive state management without RxJS complexity. Perfect for handling real-time offer updates.

    Firebase - Gives us authentication, real-time database, and hosting without managing servers. The free tier is generous enough for most small businesses.

    AWS S3 - Industry-standard object storage with fine-grained access control. We use three folders: private originals, public watermarked previews, and thumbnails.

    Stripe - The gold standard for payments. Their checkout flow handles PCI compliance, and webhooks notify us when payments succeed.

    Firebase Schema Design

    The database structure is denormalized for read performance:

    typescript
    interface Album {
      id: string;
      title: string;
      description: string;
      coverPhotoId?: string;
      photoCount: number;
      created: number; // timestamp
    }

    interface Photo { id: string; albumId: string; originalPath: string; // S3 key for high-res (private) watermarkedUrl: string; // Public URL for previews thumbnailUrl: string; // Small version for gallery grid price: number; // in cents acceptsOffers: boolean; // Enable "name your price" uploaded: number; }

    interface Offer { id: string; photoId: string; customerEmail: string; offerAmount: number; // in cents message?: string; status: 'pending' | 'approved' | 'rejected' | 'completed'; created: number; respondedAt?: number; }

    interface Order { id: string; // Stripe session ID photoId: string; customerEmail: string; amountPaid: number; downloadUrl: string; // Pre-signed S3 URL expiresAt: number; // 24 hours from purchase purchased: number; }

    Key decisions:

    1. Denormalized photoCount - Avoid counting child documents on every album load 2. Prices in cents - Never use floats for money (Stripe's best practice) 3. Pre-signed URLs - S3 generates temporary download links, no permanent public access 4. Status enum - Makes offer workflow crystal clear

    Image Processing Pipeline

    When a photo is uploaded, we create three versions:

    typescript
    // photo.service.ts
    async uploadPhoto(
      file: File, 
      albumId: string, 
      price: number
    ): Promise<Photo> {
      const formData = new FormData();
      formData.append('file', file);
      formData.append('albumId', albumId);
      formData.append('price', price.toString());
      
      // Backend API handles the heavy lifting
      const response = await this.http.post<{
        originalPath: string;
        watermarkedUrl: string;
        thumbnailUrl: string;
        width: number;
        height: number;
      }>(${this.apiUrl}/upload, formData).toPromise();
      
      // Save metadata to Firebase
      const photoRef = push(ref(this.db, 'photos'));
      const photo: Photo = {
        id: photoRef.key!,
        albumId,
        originalPath: response.originalPath,
        watermarkedUrl: response.watermarkedUrl,
        thumbnailUrl: response.thumbnailUrl,
        price: price * 100, // Convert to cents
        acceptsOffers: true,
        uploaded: Date.now()
      };
      
      await set(photoRef, photo);
      return photo;
    }

    Backend (Node.js + Sharp):

    javascript
    const sharp = require('sharp');
    const AWS = require('aws-sdk');
    const s3 = new AWS.S3();

    app.post('/api/photos/upload', async (req, res) => { const { file, albumId, price } = req; const buffer = file.buffer; // 1. Upload original (private) const originalKey = originals/${albumId}/${Date.now()}_${file.originalname}; await s3.putObject({ Bucket: process.env.S3_BUCKET, Key: originalKey, Body: buffer, ContentType: file.mimetype, // Private - no public access }).promise(); // 2. Create watermarked version (max 2000px) const watermarkBuffer = await sharp(buffer) .resize(2000, 2000, { fit: 'inside', withoutEnlargement: true }) .composite([{ input: Buffer.from(` <svg width="400" height="100"> <text x="10" y="50" font-family="Arial" font-size="30" fill="white" opacity="0.6"> © Ricardo Gil </text> </svg> `), gravity: 'southeast' }]) .jpeg({ quality: 85 }) .toBuffer(); const watermarkedKey = watermarked/${albumId}/${Date.now()}_${file.originalname}; await s3.putObject({ Bucket: process.env.S3_BUCKET, Key: watermarkedKey, Body: watermarkBuffer, ContentType: 'image/jpeg', ACL: 'public-read' // Previews are public }).promise(); // 3. Create thumbnail (800px) const thumbnailBuffer = await sharp(buffer) .resize(800, 800, { fit: 'inside' }) .jpeg({ quality: 80 }) .toBuffer(); const thumbnailKey = thumbnails/${albumId}/${Date.now()}_${file.originalname}; await s3.putObject({ Bucket: process.env.S3_BUCKET, Key: thumbnailKey, Body: thumbnailBuffer, ContentType: 'image/jpeg', ACL: 'public-read' }).promise(); res.json({ originalPath: originalKey, watermarkedUrl: https://${process.env.S3_BUCKET}.s3.amazonaws.com/${watermarkedKey}, thumbnailUrl: https://${process.env.S3_BUCKET}.s3.amazonaws.com/${thumbnailKey}, width: metadata.width, height: metadata.height }); });

    Why three versions?

  • Originals - Full resolution, private, only accessible after purchase
  • Watermarked - Medium res for detailed preview, copyright protection
  • Thumbnails - Fast loading gallery grid, bandwidth savings
  • The Offer System (Unique Feature)

    Most photography sites have fixed prices. We added "name your price" inspired by platforms like Humble Bundle:

    Customer Side (Angular):

    typescript
    // offer-dialog.component.ts
    export class OfferDialogComponent {
      photo = input.required<Photo>();
      
      offerForm = new FormGroup({
        email: new FormControl('', [Validators.required, Validators.email]),
        amount: new FormControl<number>(this.photo().price / 100, [
          Validators.required,
          Validators.min(1)
        ]),
        message: new FormControl('')
      });
      
      async submitOffer() {
        if (this.offerForm.invalid) return;
        
        const offer: CreateOfferDto = {
          photoId: this.photo().id,
          customerEmail: this.offerForm.value.email!,
          offerAmount: this.offerForm.value.amount! * 100, // Convert to cents
          message: this.offerForm.value.message || ''
        };
        
        await this.offerService.createOffer(offer);
        
        // Show success message
        this.snackBar.open(
          'Offer submitted! You\'ll receive an email if accepted.',
          'OK',
          { duration: 5000 }
        );
      }
    }

    Admin Dashboard (Angular Signals):

    typescript
    // offers-admin.component.ts
    export class OffersAdminComponent {
      offers = signal<Offer[]>([]);
      
      ngOnInit() {
        // Real-time updates from Firebase
        const offersRef = ref(this.db, 'offers');
        onValue(offersRef, (snapshot) => {
          const data = snapshot.val();
          const offersList = Object.entries(data || {}).map(([id, offer]) => ({
            id,
            ...(offer as Omit<Offer, 'id'>)
          }));
          this.offers.set(offersList);
        });
      }
      
      async approveOffer(offer: Offer) {
        // Create Stripe Payment Intent
        const paymentIntent = await this.stripeService.createPaymentIntent({
          amount: offer.offerAmount,
          customerEmail: offer.customerEmail,
          photoId: offer.photoId,
          offerId: offer.id
        });
        
        // Update offer status
        await update(ref(this.db, offers/${offer.id}), {
          status: 'approved',
          respondedAt: Date.now(),
          paymentIntentId: paymentIntent.id
        });
        
        // Email customer with payment link
        await this.emailService.sendApprovalEmail({
          to: offer.customerEmail,
          paymentUrl: paymentIntent.url,
          amount: offer.offerAmount / 100,
          photoUrl: await this.getPhotoThumbnail(offer.photoId)
        });
      }
      
      async rejectOffer(offer: Offer) {
        await update(ref(this.db, offers/${offer.id}), {
          status: 'rejected',
          respondedAt: Date.now()
        });
        
        await this.emailService.sendRejectionEmail(offer.customerEmail);
      }
    }

    Why this works:

    1. Low-friction for customers (just email + amount) 2. Admin has full control (approve only good offers) 3. Creates engagement ("Will they accept my $20 offer?") 4. Potential for higher revenue (some offers exceed asking price!)

    Stripe Integration

    We use Stripe Checkout for simplicity and security:

    typescript
    // payment.service.ts
    async createCheckoutSession(
      photoId: string, 
      amount: number, 
      customerEmail: string
    ): Promise<string> {
      const response = await this.http.post<{ sessionId: string }>(
        ${this.apiUrl}/create-checkout,
        { photoId, amount, customerEmail }
      ).toPromise();
      
      // Redirect to Stripe Checkout
      const stripe = await loadStripe(environment.stripePublishableKey);
      await stripe!.redirectToCheckout({
        sessionId: response.sessionId
      });
      
      return response.sessionId;
    }

    Backend creates the session:

    javascript
    app.post('/api/payments/create-checkout', async (req, res) => {
      const { photoId, amount, customerEmail } = req.body;
      
      const session = await stripe.checkout.sessions.create({
        payment_method_types: ['card'],
        customer_email: customerEmail,
        line_items: [{
          price_data: {
            currency: 'usd',
            product_data: {
              name: 'Photography Download',
              description: Photo ID: ${photoId},
            },
            unit_amount: amount, // in cents
          },
          quantity: 1,
        }],
        mode: 'payment',
        success_url: ${process.env.FRONTEND_URL}/payment/success?session_id={CHECKOUT_SESSION_ID},
        cancel_url: ${process.env.FRONTEND_URL}/payment/cancel,
        metadata: { photoId } // Critical for webhook
      });
      
      res.json({ sessionId: session.id });
    });

    Webhook handles successful payments:

    javascript
    app.post('/api/webhooks/stripe', async (req, res) => {
      const sig = req.headers['stripe-signature'];
      let event;
      
      try {
        event = stripe.webhooks.constructEvent(
          req.body, 
          sig, 
          process.env.STRIPE_WEBHOOK_SECRET
        );
      } catch (err) {
        return res.status(400).send(Webhook Error: ${err.message});
      }
      
      if (event.type === 'checkout.session.completed') {
        const session = event.data.object;
        const photoId = session.metadata.photoId;
        
        // Generate pre-signed URL (valid 24 hours)
        const photo = await getPhotoFromFirebase(photoId);
        const downloadUrl = await s3.getSignedUrlPromise('getObject', {
          Bucket: process.env.S3_BUCKET,
          Key: photo.originalPath,
          Expires: 86400 // 24 hours
        });
        
        // Save order to Firebase
        await saveOrder({
          id: session.id,
          photoId,
          customerEmail: session.customer_email,
          amountPaid: session.amount_total,
          downloadUrl,
          expiresAt: Date.now() + 86400000, // 24 hours
          purchased: Date.now()
        });
        
        // Email download link
        await sendDownloadEmail({
          to: session.customer_email,
          downloadUrl,
          expiresIn: '24 hours'
        });
      }
      
      res.json({ received: true });
    });

    Security Considerations

    1. Original Photos Never Public

    S3 originals are private. Only pre-signed URLs grant temporary access.

    2. Download Links Expire

    After 24 hours, pre-signed URLs stop working. No permanent download links.

    3. Firebase Rules

    json
    {
      "rules": {
        "albums": {
          ".read": true,
          ".write": "auth != null && auth.token.admin === true"
        },
        "photos": {
          ".read": true,
          ".write": "auth != null && auth.token.admin === true"
        },
        "offers": {
          ".read": "auth != null && auth.token.admin === true",
          ".write": true,
          "$offerId": {
            ".read": true
          }
        },
        "orders": {
          ".read": "auth != null && auth.token.admin === true"
        }
      }
    }

    4. Webhook Signature Verification

    Always verify Stripe webhook signatures to prevent fake payment events.

    5. CORS Configuration

    Backend only accepts requests from your frontend domain.

    Performance Optimizations

    1. Lazy Loading Images

    typescript
    // gallery.component.ts
    photos = signal<Photo[]>([]);
    loading = signal(true);

    async loadPhotos(albumId: string) { this.loading.set(true); const photosRef = ref(this.db, 'photos'); const photosQuery = query( photosRef, orderByChild('albumId'), equalTo(albumId) ); const snapshot = await get(photosQuery); const data = snapshot.val(); const photosList = Object.entries(data || {}).map(([id, photo]) => ({ id, ...(photo as Omit<Photo, 'id'>) })); this.photos.set(photosList); this.loading.set(false); }

    Template with Angular's built-in lazy loading:

    html
    <!-- gallery.component.html -->
    @for (photo of photos(); track photo.id) {
      <div class="photo-card">
        <img 
          [src]="photo.thumbnailUrl" 
          [alt]="photo.id"
          loading="lazy"
          (click)="viewPhoto(photo)">
        <div class="photo-info">
          <span class="price">${{ photo.price / 100 }}</span>
          @if (photo.acceptsOffers) {
            <span class="badge">Accepts Offers</span>
          }
        </div>
      </div>
    }

    2. Firebase Indexing

    Create an index on albumId for fast photo queries:

    json
    {
      "rules": { / ... / },
      "indexes": {
        "photos": {
          ".indexOn": ["albumId", "uploaded"]
        }
      }
    }

    3. CDN for Static Assets

    Serve watermarked images through CloudFront or CloudFlare for global caching.

    Cost Breakdown

    Running this in production for a small photography business:

    AWS S3:

  • Storage: $0.023/GB/month
  • 100GB of photos = ~$2.30/month
  • GET requests: $0.0004 per 1,000 requests
  • 10,000 views/month = ~$0.004/month
  • Firebase:

  • Free tier: 1GB storage, 10GB bandwidth/month
  • Realtime Database: $5/GB after free tier
  • Typical usage: Under $5/month
  • Stripe:

  • 2.9% + $0.30 per transaction
  • $50 sale = $1.75 to Stripe
  • Hosting (Backend):

  • Self-hosted on Raspberry Pi = $0
  • Or Railway/Render = $5-10/month
  • Total Monthly Cost: $8-18 for a small business processing $500-2,000/month in sales.

    Lessons Learned

    1. Pre-signed URLs Are Gold

    Don't expose your S3 bucket publicly. Pre-signed URLs give you fine-grained control over who can download what and for how long.

    2. Always Use Cents for Money

    Floats and money don't mix. Stripe uses cents, so we do too. Converts to dollars only for display.

    3. Webhooks > Polling

    Don't poll Stripe to check if payment succeeded. Use webhooks and let them notify you.

    4. Firebase Rules Are Your Friend

    Secure your data with tight security rules. Public read, authenticated write is a good starting point.

    5. Signals Simplify State Management

    Angular Signals removed tons of RxJS complexity. Real-time updates from Firebase are now dead simple.

    What's Next?

    Potential Enhancements:

  • Packages/Collections - Sell multiple photos as a bundle
  • Client Galleries - Private albums with password access
  • Print Fulfillment - Integration with Printful or similar
  • Watermark Customization - Let users customize their copyright text
  • Analytics Dashboard - Track views, conversion rates, popular photos
  • Multi-photographer - Add team accounts and revenue splitting
  • Could This Be a Product?

    I've thought about packaging this as a SaaS for photographers. The tech stack is solid, the features are unique (especially the offer system), and the economics work.

    If you're interested in something like this for your photography business, or you're a developer wanting to build something similar, reach out. I'd love to hear your thoughts.

    Conclusion

    Building a photography e-commerce platform taught me a lot about payment systems, image processing, and real-time data synchronization. The combination of Angular 20's Signals, Firebase's real-time capabilities, and Stripe's robust payment APIs creates a powerful foundation.

    The "name your price" offer system turned out to be the killer feature—it creates engagement and often results in higher revenue than fixed pricing.

    Key Takeaways:

    ✅ Angular 20 + Firebase = Real-time updates without complexity ✅ S3 multi-tier storage = Security + Performance ✅ Stripe Checkout = PCI compliance solved ✅ Pre-signed URLs = Temporary access without permanent exposure ✅ Offer system = Higher engagement and revenue

    Source Code: While the full source isn't open yet, I'm considering releasing it as a template or SaaS product. Follow my blog for updates!

    ---

    Have questions about the implementation? Want to see a specific feature deep-dive? Drop a comment below or reach out on LinkedIn.

    Related Posts:

  • Firebase Authentication with Angular: Complete Guide
  • Self-Hosting with Tailscale: Secure Remote Access
  • PostgreSQL Performance Optimization: 40% Improvement
  • Tags: #Angular #Firebase #Stripe #AWS #Photography #Ecommerce #WebDev #FullStack

    Related Posts

    Building with Angular + Firebase? Check out my Photography E-Commerce Platform that combines Firebase auth with Stripe payments and AWS S3 storage.

    Self-hosting your projects? Learn how I host production apps on a Raspberry Pi with PM2, Nginx, and Tailscale.

    About the Author: Ricardo Gil is a full-stack software engineer specializing in .NET/C#, Angular, and cloud platforms. Read more or subscribe for updates.

    📬Weekly Newsletter

    Get the best home lab & AI content

    No spam. One email per week. Unsubscribe anytime.

    Share this article