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
11 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.

Need this built for your business?

I build bilingual (EN/ES) web apps and sites with .NET, Angular and AWS: production-ready, documented and handed off. See Full-Stack Development →

Book a free discovery call.

📬Weekly Newsletter

Get the best home lab & AI content

No spam. One email per week. Unsubscribe anytime.

Share this article