DEV Community

How I Built a No-App Photo Sharing Platform Using Just QR Codes and Browser Cameras

Last summer, I was at my cousin's wedding. 200 guests, one professional photographer, and exactly zero way for anyone else to share the photos they were taking on their phones. The bride spent weeks chasing people through WhatsApp groups and Facebook messages, trying to collect the candid shots everyone promised to send. She got maybe 30 photos out of what must have been thousands taken that night. That moment stuck with me. I'm a developer, and I kept thinking: there has to be a better way. Not another app to download. Not another account to create. Something so simple that even your tech-averse uncle could use it after his third glass of wine. So I built Picshots - a no-app photo sharing platform that works entirely through QR codes and browser cameras. No downloads, no sign-ups, no "check your email for a verification code." Just scan, snap, and the photos land in a shared gallery. Here's exactly how I built it, what I learned, and the technical decisions that made it work. The Core Problem: Friction Kills Participation Here's a stat that shaped every decision I made: for every additional step between a guest and their first photo upload, you lose roughly 40% of potential participants. I didn't pull that from a research paper - I tested it. I built a prototype that required guests to enter their name before taking a photo. Then I removed the name field. The difference? A 3x increase in photos captured. The math is brutal: App Store โ†’ find app โ†’ download โ†’ install โ†’ open โ†’ create account โ†’ verify email โ†’ find event โ†’ take photo = ~5% participation Scan QR โ†’ camera opens โ†’ take photo โ†’ done = ~90% participation That 85% gap is the difference between a dead gallery and one with 500+ photos by the end of the night. The no-download approach isn't a nice-to-have - it's the entire product. The Tech Stack: What Powers a Browser-Based Photo Platform Before diving into the code, here's the stack I landed on after several iterations: LayerTechnologyWhy Camera AccessMediaDevices.getUserMedia()Works in every modern browser, no polyfills needed QR Generationqrcode (npm, 82M monthly downloads)Battle-tested, supports SVG output for crisp printing QR Scanninghtml5-qrcode (npm, 5M monthly downloads)Pure JS, no WASM, works on mobile browsers Image UploadPresigned S3 URLsBypasses server bottlenecks on large files Real-time GallerySupabase RealtimeWebSocket-based, no polling, scales to thousands of concurrent viewers FrontendNext.js + TailwindSSR for SEO pages, CSR for the camera experience HostingVercel + S3 + SupabaseEdge functions for QR redirects, S3 for photos, Supabase for metadata Let me walk through each piece and the decisions behind them. Step 1: Accessing the Camera Without an App The getUserMedia API is the unsung hero of this entire project. It's been available in browsers since 2015, but most people don't realize how capable it is. Here's the core camera initialization code: const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', // Use back camera on mobile width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false }); const video = document.getElementById('camera-preview'); video.srcObject = stream; await video.play(); Three things I learned the hard way: - HTTPS is non-negotiable. getUserMedia only works onlocalhost or HTTPS. If you're testing on a device over your local network, you need a self-signed cert or a tunnel like ngrok. I wasted an afternoon debugging this before remembering it's a browser security requirement. - iOS Safari has quirks. On iOS, getUserMedia must be triggered by a user gesture (tap/click). You can't auto-open the camera on page load. I added a prominent "Open Camera" button that's impossible to miss, and the tap satisfies Safari's requirement. - The facingMode: 'environment' constraint is a suggestion, not a command. Some Android browsers ignore it and default to the front camera. I added a camera toggle button as a fallback - it's saved me from countless "why am I looking at my own face?" support messages. Step 2: QR Codes - The Zero-Friction Entry Point QR codes are the bridge between the physical event and the digital gallery. Every event on Picshots gets a unique QR code that, when scanned, opens the camera directly in the guest's browser. No typing URLs, no searching for the event - just point and shoot. I used the qrcode npm package (82 million monthly downloads - it's basically the standard at this point) to generate QR codes server-side: import QRCode from 'qrcode'; const eventUrl = https://picshots.app/e/${eventId}; const qrSvg = await QRCode.toString(eventUrl, { type: 'svg', errorCorrectionLevel: 'H', // High - survives up to 30% damage margin: 2, width: 400, color: { dark: '#1a1a2e', light: '#ffffff' } }); Key decisions here: SVG over PNG: SVGs scale infinitely without pixelation. Event hosts print these QR codes on everything from table cards (2ร—2 inches) to welcome banners (4ร—6 feet). A PNG would look terrible at banner size. Error correction level H: This allows the QR code to remain scannable even if up to 30% of it is damaged or obscured. At a wedding, QR codes get wine spilled on them, folded, or partially covered by centerpieces. Level H has saved countless scans. Short URLs matter: The less data in a QR code, the larger and more scannable each module (those little squares) becomes. I use short event IDs ( /e/abc123 ) rather than long UUIDs to keep the QR code clean and scannable from a distance. For the scanning side, I use html5-qrcode (5 million monthly downloads) for the rare case where someone needs to scan a QR code from within the browser - for example, if a host wants to join their own event from a laptop. It's pure JavaScript, no WebAssembly, and works reliably on mobile browsers. Step 3: Capturing and Uploading Photos Once the camera is running, capturing a photo is straightforward - grab a frame from the video stream and draw it to a canvas: function capturePhoto(videoElement) { const canvas = document.createElement('canvas'); canvas.width = videoElement.videoWidth; canvas.height = videoElement.videoHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(videoElement, 0, 0); return canvas.toBlob('image/jpeg', 0.85); } The upload pipeline is where things get interesting. I use presigned S3 URLs to bypass the server entirely during upload: - Client requests a presigned URL from the API - Client uploads directly to S3 using that URL - S3 triggers a Lambda that generates thumbnails and stores metadata in Supabase - Supabase Realtime pushes the new photo to all connected gallery viewers This architecture means my server never touches a single byte of image data. A 10MB photo from an iPhone 15 Pro Max goes straight from the guest's browser to S3. The server just handles metadata - event IDs, timestamps, and thumbnail URLs. The real-time gallery update is powered by Supabase Realtime, which uses WebSockets under the hood. When a new photo row is inserted into the photos table, every connected client gets the update within milliseconds. At a wedding with 200 guests all watching the live gallery on a projector, the photos appear almost instantly after someone snaps them. Step 4: The Hard Parts Nobody Talks About iOS Safari and the "Page Reload" Problem iOS Safari aggressively kills background tabs to save memory. If a guest switches to WhatsApp to reply to a message and comes back 30 seconds later, Safari may have killed the camera stream. The page reloads, and suddenly they're staring at the event landing page instead of the camera. My fix: I store the camera state in sessionStorage . If the page reloads and detects a previous camera session, it auto-reopens the camera without requiring another QR scan. It's a small detail, but it's the difference between a guest taking 3 photos and taking 15. Orientation Lock on Mobile When a guest rotates their phone from portrait to landscape mid-capture, the video stream dimensions change. If you're not handling the resize event on the video element, your canvas capture will be stretched or cropped. I learned this the hard way when the first batch of test photos came back looking like funhouse mirrors. video.addEventListener('resize', () => { canvas.width = video.videoWidth; canvas.height = video.videoHeight; }); Concurrent Upload Limits Browsers limit concurrent connections to the same origin (usually 6). At a wedding with 200 guests all uploading photos simultaneously, you can hit this limit fast. Presigned S3 URLs solve this because each upload goes to a unique URL - effectively bypassing the per-origin connection limit. I also added a simple upload queue with a concurrency limit of 3 to avoid overwhelming the device's network stack. Step 5: The Gallery Experience The gallery is where the magic happens. All photos appear in a responsive grid, sorted by capture time, with a subtle fade-in animation. Hosts can project the gallery on a screen at the venue, and guests can watch photos appear in real time throughout the night. I built the gallery with a few key features: Lazy loading with blur-up placeholders: Thumbnails load first as tiny (20ร—20) blurred images, then resolve to full resolution. On a gallery with 500+ photos, this keeps the initial page load under 2 seconds. Infinite scroll with virtualization: Only ~20 photos are in the DOM at any time. As you scroll, photos are recycled. Without this, a 500-photo gallery would bring even a flagship phone to its knees. Download all as ZIP: After the event, hosts can download every photo as a single ZIP file. This is generated server-side using archiver and streamed directly from S3 - no temporary files on disk. The Results: What 12,000+ Events Taught Me Since launching, Picshots has been used at over 12,000 events - weddings, birthday parties, corporate galas, baby showers, you name it. Here's what the data shows: 92% guest participation rate - meaning 92% of guests who scan the QR code take at least one photo Average of 8.3 phot

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.