A look at the tech stack, architecture decisions, security hardening, and lessons learned building the Edinburgh Azure User Group community platform with Next.js 16, Azure CosmosDB, LinkedIn auth, Cloudinary, FireHOL IP blocking, and GitHub Actions.
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 12 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.
We use LinkedIn OAuth exclusively. The reasoning is simple: we already use LinkedIn to promote our events and run our community group, so members almost certainly have an account there already. No extra password to remember, no email confirmation loop — just one click.
NextAuth v5 (beta) handles the OAuth flow, 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.
Transactional emails (RSVP confirmations, welcome emails, monthly newsletter, topic voting) are sent via Loops. 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.
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 ~300 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.
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.
What the site can do today:
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.all() 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.
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!