A look at the tech stack, architecture decisions, and lessons learned building the EAUG community platform — covering event check-in with QR codes, digital badges, speaker workflows, member directory, email sign-in codes, and more.
The Edinburgh Azure User Group (EAUG) needed a home on the web — a place where members could find upcoming events, RSVP, submit talk proposals, and explore past content. Rather than reaching for an off-the-shelf platform, we decided to build something tailored to our needs using the Azure ecosystem we know and love.
Here is a look at the technology choices, architecture decisions, and lessons learned along the way.
We chose Next.js 16 with the App Router and React 19 as the foundation. The App Router gives us a clean separation between server and client components, built-in caching with unstable_cache, and server actions that eliminate the need for a separate API layer.
CosmosDB was a natural fit — we are an Azure user group after all. We use the NoSQL (SQL API) variant with 14 containers:
We use a soft-delete pattern throughout — records are never hard deleted, just marked with deleted: true. This keeps history intact and makes accidental deletions recoverable.
LinkedIn OAuth is the primary sign-in method. The reasoning is simple: we already use LinkedIn to promote our events, so members almost certainly have an account there.
We also built a 6-digit email sign-in code as a fallback for existing members. When enabled, members can enter their email and receive a short-lived numeric code via Loops rather than going through LinkedIn. The code is stored in the MAGIC_CODES container with a 15-minute expiry. Crucially, the code path only works for emails already registered via LinkedIn — this prevents duplicate accounts from being created.
LinkedIn-specific features (posting attendance to LinkedIn, the LinkedIn picture toggle) are automatically greyed out with a tooltip when the user is not signed in via LinkedIn.
NextAuth v5 (beta) handles both flows, and we wrote a custom CosmosDB adapter so sessions and accounts are stored directly in CosmosDB rather than needing a separate database.
Tailwind CSS v4 with the new OKLch colour system powers all the styling. We built a shadcn-inspired component library on top of @base-ui/react — Card, Button, Dialog, Badge, Table, Separator, Input, Textarea — with dark mode as the default.
Event photos, cover images, and slide PDFs are stored in Cloudinary. Uploads go directly from the browser to Cloudinary using an unsigned upload preset — no server-side API secret is needed for the upload itself. Files are automatically organised by event ID in folders like eaug/events/{eventId}/cover, eaug/events/{eventId}/photos, and eaug/events/{eventId}/slides.
A particularly neat trick: Cloudinary can serve a JPEG preview of a PDF's first page by changing /raw/upload/ to /image/upload/w_300,h_200,c_pad,pg_1,f_jpg/ in the URL. No extra processing step needed — the thumbnail is generated on demand.
All transactional emails are sent via Loops. We now have ten email types — RSVP confirmations, welcome emails, the monthly newsletter, topic voting, talk status notifications (submitted, approved, rejected), the 6-digit sign-in code, and the event check-in QR code email.
Each email type has its own template ID configured as an optional environment variable. If not set, the send is silently skipped so the rest of the action continues. MJML source templates for all ten emails live in the external/ folder alongside the codebase.
The talk submission form has three layers of protection:
One addition we are particularly happy with is network-level IP blocking using the FireHOL Level 1 blocklist.
Next.js 16 introduced proxy.ts — an Edge-runtime file that runs before any route or auth logic. We use it to check every incoming IP against the FireHOL Level 1 list, a community-maintained set of known-malicious IPs and CIDR ranges updated daily.
The implementation is zero-dependency and Edge-compatible (no Node.js net module — just 32-bit integer arithmetic for CIDR matching). The blocklist is fetched once and cached in module scope for 6 hours. Critically, it fails open — if the feed is unavailable, traffic is never blocked, so a GitHub outage can never take the site down.
Blocked IPs get a 403 before they ever touch the database, auth layer, or any server action.
Running a smooth check-in at the door is one of those organisational details that makes a real difference to the member experience. We built a hybrid system that covers two scenarios: the member who has their phone and can show a QR code, and the member who just walks up and needs to be found by name.
A Vercel cron job fires at 8am on any day an event is scheduled. It finds all RSVPed attendees who have not yet received a QR email, generates a signed JWT token for each one (HMAC-HS256, using AUTH_SECRET, scoped to userId + eventId and expiring at midnight), renders it as a PNG using the qrcode package, and sends it via Loops.
The email is built in MJML and shows the event title, date, and the QR code prominently. Members open it on their phone and show it at the door. No app, no login required — just the email.
A deduplication guard (checkin_email_sent_ids on the USERIDS_BY_EVENTID document) prevents re-sending if the cron fires more than once for the same event.
The check-in page at /admin/events-management/:id/checkin is designed to be open on a tablet or laptop at the entrance.
It has three sections:
Both paths write to the same checked_in_ids array on the USERIDS_BY_EVENTID document.
The check-in toggle in Application Settings lets organisers enable or disable the QR email independently of the check-in page itself.
We put some thought into making the talk submission and approval flow smooth for both organisers and speakers.
When a talk is approved, the system automatically resolves the speaker's bio through a three-step fallback:
1. If the speaker is a registered member, pull their profile bio
2. If no profile bio, search for their most recently approved talk by the same email address and carry the bio forward
3. If still nothing, generate a UUID token and include a tokenised link in the approval email pointing to /speaker-bio/:token — a public page where external speakers can submit their bio without needing an account
The approval email sent via Loops includes the correct bio URL — member profile link for registered members, the tokenised link for externals.
Speakers can track the status of their proposals in the My Talks page in the member area. Approved talks without a bio show a "Bio needed" link that goes directly to the profile editor.
The profile page also shows a read-only Speaker History card listing all approved and given talks.
We wanted a way to recognise community milestones — first attendance, speaking, five events attended, becoming an organiser. Rather than building a custom display system from scratch, we built a provider-agnostic badge abstraction that sits in front of Credly or Badgr.
Badges are gated by the badges_enabled feature flag in Application Settings. When enabled:
A duplicate guard (id = {userId}_{badge_type} in the BADGES container) ensures no badge is issued twice regardless of how many times the cron runs.
Earned badges are displayed on the member profile page in a Badges card with the badge image, name, and issue date.
Members can opt into a public profile in the Your Peers section of the member area. The directory shows avatars, names, Azure skills, and experience level. Each member controls:
The directory is gated by the member_directory_enabled feature flag.
The CI/CD pipeline is a reusable GitHub Actions workflow. On every push to staging or main:
1. Node.js 24 and pnpm v10 are set up
2. Dependencies are installed (with pnpm store caching)
3. Vitest runs the unit test suite — build fails if any test fails
4. Next.js builds with output: standalone, producing a self-contained server bundle
5. Static assets are moved into the standalone folder
6. Only the standalone bundle (~15 MB) is uploaded as a GitHub Actions artifact, with a 1-day retention — not the full workspace (~170 MB)
7. Trivy scans for CRITICAL and HIGH vulnerabilities in parallel with the build job
8. The artifact is deployed to Azure Web App only when both build and Trivy pass
The standalone output means the Azure Web App just runs node server.js — no separate build step at runtime, no large node_modules to copy.
We use Vitest for unit tests. The test suite covers:
Tests run in CI before the build step, so a failing test blocks deployment.
A few things that caught us out or proved unexpectedly useful:
Next.js standalone builds need a manual step to fix @swc/helpers — the pnpm symlink structure means the traced dependencies miss the real files. We copy them explicitly in CI.
The relativeUrl Zod validator must reject protocol-relative URLs. A regex of /^// passes //evil.com, which browsers treat as an absolute URL. The correct pattern is /^/[^/]/ — the second character must not be a slash.
The getAllUsers query was silently capped at 100 results by the CosmosDB SDK default. We added { maxItemCount: -1 } to the query options to fetch all users — critical for newsletter and voting email sends.
Sending emails sequentially to 200+ members took over 30 seconds and hit serverless timeouts. Switching to Promise.allSettled() for the send loop dropped it to under 5 seconds.
Cloudinary's unsigned upload preset is all you need for browser-to-CDN uploads. The API key and secret are only needed for server-side operations — keep them out of NEXT_PUBLIC_ variables.
Date formatting without a locale argument produces different output on Node (server) and the browser, causing React hydration errors. Always pass an explicit locale: toLocaleDateString('en-GB').
GitHub Actions artifact storage has a hard quota. Uploading the full workspace on every run hits it quickly. Uploading only the standalone output (./build/standalone) with retention-days: 1 keeps storage near zero.
The @zxing/browser QR scanner library needs the camera stream stopped explicitly — calling getTracks().forEach(t => t.stop()) on the MediaStream when the component unmounts, otherwise the camera indicator stays on in the browser tab even after navigation.
What the site can do today:
We build this in the open and use it as a demonstration of what you can do with the Azure ecosystem. The source is available on GitHub and we are happy to talk through any part of it at a future meetup.
See you at the next event!