Deploy React SPAs to Azure Static Web Apps with Hono backend, Drizzle ORM, Docker local development, and Entra ID SSO. A comprehensive Brisbane developer guide with production-ready patterns.
Introduction
Building modern web applications requires a stack that handles frontend hosting, serverless APIs, database operations, enterprise authentication, and local development seamlessly. This guide shows you how to deploy a React SPA to Azure Static Web Apps with a Hono-powered API, Drizzle ORM for PostgreSQL, Docker-based local development, and Microsoft Entra ID SSO.
This architecture powers enterprise internal portals across Queensland, combining the performance of edge-distributed static hosting with type-safe serverless APIs and relational databases.
Prerequisites
brew tap azure/functions
brew install azure-functions-core-tools@4Architecture Overview
Azure Static Web Apps provides a unified hosting solution for modern web applications, automatically deploying static content to a global CDN while routing API requests to serverless functions.
Technology Stack
| Layer | Technology | Purpose |
|---|---|---|
| Monorepo | Turborepo | Parallel builds, workspace management |
| Frontend | React + Vite + Tailwind | SPA with hot reload, utility-first CSS |
| Routing | React Router v6 | Client-side navigation |
| API Framework | Hono | Lightweight, TypeScript-first (14KB) |
| Validation | Zod + @hono/zod-validator | Type-safe request validation |
| Database | Drizzle ORM + PostgreSQL | Type-safe queries, migrations |
| Auth | MSAL React + Entra ID | Enterprise SSO with MFA |
| Local Dev | Docker Compose | PostgreSQL + Azurite emulation |
Project Structure
This is a Turborepo monorepo with frontend and API workspaces:
Turborepo Configuration
Root package.json
1{2 "name": "my-app",3 "private": true,4 "workspaces": ["frontend", "api"],5 "scripts": {6 "dev": "turbo run dev --parallel",7 "build": "turbo run build",8 "lint": "turbo run lint",9 "docker:up": "docker-compose up -d",10 "docker:down": "docker-compose down",11 "docker:reset": "docker-compose down -v && docker-compose up -d"12 },13 "devDependencies": {14 "turbo": "^2.3.0"15 }16}turbo.json
1{2 "$schema": "https://turbo.build/schema.json",3 "tasks": {4 "dev": {5 "cache": false,6 "persistent": true7 },8 "build": {9 "dependsOn": ["^build"],10 "outputs": ["dist/**", "build/**"]11 },12 "lint": {13 "dependsOn": ["^lint"]14 }15 }16}Frontend Setup
Vite Configuration
1// frontend/vite.config.ts2import { defineConfig } from 'vite';3import react from '@vitejs/plugin-react';4import path from 'path';56export default defineConfig({7 plugins: [react()],8 resolve: {9 alias: {10 '@': path.resolve(__dirname, './src'),11 },12 },13 server: {14 port: 5199,15 proxy: {16 '/api': {17 target: 'http://localhost:7071',18 changeOrigin: true,19 },20 },21 },22});MSAL Configuration
1// frontend/src/config/auth.ts2import { Configuration, LogLevel } from '@azure/msal-browser';34export const msalConfig: Configuration = {5 auth: {6 clientId: import.meta.env.VITE_AZURE_CLIENT_ID,7 authority: `https://login.microsoftonline.com/${import.meta.env.VITE_AZURE_TENANT_ID}`,8 redirectUri: window.location.origin,9 postLogoutRedirectUri: window.location.origin,10 navigateToLoginRequestUrl: true,11 },12 cache: {13 cacheLocation: 'sessionStorage', // More secure than localStorage14 storeAuthStateInCookie: false,15 },16 system: {17 loggerOptions: {18 loggerCallback: (level, message, containsPii) => {19 if (containsPii) return;20 if (level === LogLevel.Error) console.error(message);21 if (level === LogLevel.Warning) console.warn(message);22 },23 logLevel: import.meta.env.DEV ? LogLevel.Warning : LogLevel.Error,24 },25 },26};2728export const loginRequest = {29 scopes: ['openid', 'profile', 'email'],30};Custom Auth Hook
1// frontend/src/hooks/useAuth.ts2import { useMsal, useAccount, useIsAuthenticated } from '@azure/msal-react';3import { useMemo, useCallback } from 'react';4import { loginRequest } from '@/config/auth';56interface EntraIdProfile {7 id: string;8 email: string;9 name: string;10 givenName?: string;11 familyName?: string;12 jobTitle?: string;13 department?: string;14 officeLocation?: string;15}1617export function useAuth() {18 const { instance, accounts, inProgress } = useMsal();19 const account = useAccount(accounts[0] || null);20 const isAuthenticated = useIsAuthenticated();2122 const user = useMemo(() => {23 if (!account) return null;24 return {25 id: account.localAccountId,26 email: account.username,27 name: account.name || account.username,28 };29 }, [account]);3031 const profile = useMemo<EntraIdProfile | null>(() => {32 if (!account?.idTokenClaims) return null;33 const claims = account.idTokenClaims as Record<string, unknown>;34 return {35 id: account.localAccountId,36 email: account.username,37 name: account.name || account.username,38 givenName: claims.given_name as string,39 familyName: claims.family_name as string,40 jobTitle: claims.jobTitle as string,41 department: claims.department as string,42 officeLocation: claims.officeLocation as string,43 };44 }, [account]);4546 const login = useCallback(async () => {47 await instance.loginRedirect(loginRequest);48 }, [instance]);4950 const logout = useCallback(async () => {51 await instance.logoutRedirect({52 postLogoutRedirectUri: window.location.origin,53 });54 }, [instance]);5556 return {57 user,58 profile,59 isAuthenticated,60 isLoading: inProgress !== 'none',61 login,62 logout,63 };64}API Client
1// frontend/src/lib/api/client.ts2export class ApiError extends Error {3 constructor(4 public status: number,5 message: string6 ) {7 super(message);8 this.name = 'ApiError';9 }10}1112async function request<T>(13 endpoint: string,14 options: RequestInit = {}15): Promise<T> {16 const url = `/api${endpoint}`;1718 const response = await fetch(url, {19 ...options,20 headers: {21 'Content-Type': 'application/json',22 ...options.headers,23 },24 });2526 if (!response.ok) {27 const error = await response.json().catch(() => ({}));28 throw new ApiError(response.status, error.message || 'Request failed');29 }3031 return response.json();32}3334export const api = {35 get: <T>(endpoint: string) => request<T>(endpoint),36 post: <T>(endpoint: string, data: unknown) =>37 request<T>(endpoint, { method: 'POST', body: JSON.stringify(data) }),38 put: <T>(endpoint: string, data: unknown) =>39 request<T>(endpoint, { method: 'PUT', body: JSON.stringify(data) }),40 patch: <T>(endpoint: string, data: unknown) =>41 request<T>(endpoint, { method: 'PATCH', body: JSON.stringify(data) }),42 delete: <T>(endpoint: string) =>43 request<T>(endpoint, { method: 'DELETE' }),44};Data Fetching Hook
1// frontend/src/hooks/useServices.ts2import { useState, useEffect, useCallback } from 'react';3import { api } from '@/lib/api/client';45interface Service {6 id: string;7 name: string;8 slug: string;9 status: string;10 shortDescription: string;11 platform?: { name: string };12 category?: { name: string; color: string };13}1415interface UseServicesParams {16 page?: number;17 limit?: number;18 search?: string;19 categoryId?: string;20 sortBy?: string;21}2223export function useServices(params: UseServicesParams = {}) {24 const [services, setServices] = useState<Service[]>([]);25 const [pagination, setPagination] = useState({ page: 1, total: 0, totalPages: 0 });26 const [isLoading, setIsLoading] = useState(true);27 const [error, setError] = useState<Error | null>(null);2829 const fetchServices = useCallback(async () => {30 setIsLoading(true);31 setError(null);3233 try {34 const queryParams = new URLSearchParams();35 if (params.page) queryParams.set('page', String(params.page));36 if (params.limit) queryParams.set('limit', String(params.limit));37 if (params.search) queryParams.set('search', params.search);38 if (params.categoryId) queryParams.set('categoryId', params.categoryId);39 if (params.sortBy) queryParams.set('sortBy', params.sortBy);4041 const query = queryParams.toString();42 const endpoint = query ? `/services?${query}` : '/services';4344 const response = await api.get<{45 data: Service[];46 pagination: typeof pagination;47 }>(endpoint);4849 setServices(response.data);50 setPagination(response.pagination);51 } catch (err) {52 setError(err instanceof Error ? err : new Error('Failed to fetch'));53 } finally {54 setIsLoading(false);55 }56 }, [params.page, params.limit, params.search, params.categoryId, params.sortBy]);5758 useEffect(() => {59 fetchServices();60 }, [fetchServices]);6162 return { services, pagination, isLoading, error, refetch: fetchServices };63}Hono API Setup
Main Entry Point
1// api/src/index.ts2import { Hono } from 'hono';3import { logger } from 'hono/logger';4import { cors } from 'hono/cors';5import { secureHeaders } from 'hono/secure-headers';67import { healthRoutes } from './routes/health';8import { servicesRoutes } from './routes/services';9import { referenceRoutes } from './routes/reference-data';10import { errorHandler, notFoundHandler } from './middleware/error-handler';11import { rateLimiter } from './middleware/rate-limit';1213const api = new Hono().basePath('/api');1415// Middleware stack (order matters)16api.use('*', logger());17api.use('*', cors({18 origin: (origin) => {19 // Allow localhost in development20 if (origin?.includes('localhost') || origin?.includes('127.0.0.1')) {21 return origin;22 }23 // Production origins24 if (origin?.endsWith('.azurestaticapps.net')) {25 return origin;26 }27 return null;28 },29 credentials: true,30 allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],31 allowHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],32 maxAge: 600,33}));34api.use('*', secureHeaders());35api.use('*', rateLimiter({ limit: 100, window: 60 }));3637// Routes38api.route('/health', healthRoutes);39api.route('/services', servicesRoutes);40api.route('/reference', referenceRoutes);4142// Error handling43api.onError(errorHandler);44api.notFound(notFoundHandler);4546export default api;Azure Functions Entry
1// api/src/functions/httpTrigger.ts2import { app } from '@azure/functions';3import { azureHonoHandler } from '@marplex/hono-azurefunc-adapter';4import honoApp from '../index';56app.http('httpTrigger', {7 methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],8 authLevel: 'anonymous',9 route: '{*proxy}',10 handler: azureHonoHandler(honoApp.fetch),11});Services Route with Zod Validation
1// api/src/routes/services.ts2import { Hono } from 'hono';3import { zValidator } from '@hono/zod-validator';4import { z } from 'zod';5import { db } from '../db/client';6import { serviceOfferings, platforms, categories, users } from '../db/schema';7import { eq, and, or, ilike, desc, asc, sql } from 'drizzle-orm';89const listQuerySchema = z.object({10 page: z.coerce.number().positive().default(1),11 limit: z.coerce.number().min(1).max(100).default(20),12 search: z.string().optional(),13 status: z.enum(['generally_available', 'restricted', 'deprecated', 'roadmap']).optional(),14 categoryId: z.string().uuid().optional(),15 platformId: z.string().uuid().optional(),16 sortBy: z.enum(['name', 'status', 'updatedAt', 'createdAt']).default('name'),17 sortOrder: z.enum(['asc', 'desc']).default('asc'),18});1920const services = new Hono();2122// GET /api/services - List with filters23services.get(24 '/',25 zValidator('query', listQuerySchema),26 async (c) => {27 const query = c.req.valid('query');28 const offset = (query.page - 1) * query.limit;2930 // Build dynamic where conditions31 const conditions = [eq(serviceOfferings.isDeleted, false)];3233 if (query.search) {34 conditions.push(35 or(36 ilike(serviceOfferings.name, `%${query.search}%`),37 ilike(serviceOfferings.shortDescription, `%${query.search}%`)38 )!39 );40 }4142 if (query.status) {43 conditions.push(eq(serviceOfferings.status, query.status));44 }4546 if (query.categoryId) {47 conditions.push(eq(serviceOfferings.categoryId, query.categoryId));48 }4950 if (query.platformId) {51 conditions.push(eq(serviceOfferings.platformId, query.platformId));52 }5354 // Sort direction55 const sortFn = query.sortOrder === 'desc' ? desc : asc;56 const sortColumn = serviceOfferings[query.sortBy as keyof typeof serviceOfferings] || serviceOfferings.name;5758 // Execute queries in parallel59 const [items, countResult] = await Promise.all([60 db61 .select({62 id: serviceOfferings.id,63 name: serviceOfferings.name,64 slug: serviceOfferings.slug,65 status: serviceOfferings.status,66 shortDescription: serviceOfferings.shortDescription,67 platform: {68 id: platforms.id,69 name: platforms.name,70 },71 category: {72 id: categories.id,73 name: categories.name,74 color: categories.color,75 },76 })77 .from(serviceOfferings)78 .leftJoin(platforms, eq(serviceOfferings.platformId, platforms.id))79 .leftJoin(categories, eq(serviceOfferings.categoryId, categories.id))80 .where(and(...conditions))81 .orderBy(sortFn(sortColumn))82 .limit(query.limit)83 .offset(offset),8485 db86 .select({ count: sql<number>`count(*)::int` })87 .from(serviceOfferings)88 .where(and(...conditions)),89 ]);9091 const total = countResult[0]?.count || 0;9293 return c.json({94 data: items,95 pagination: {96 page: query.page,97 limit: query.limit,98 total,99 totalPages: Math.ceil(total / query.limit),100 },101 });102 }103);104105// GET /api/services/:slug - Single service106services.get('/:slug', async (c) => {107 const slug = c.req.param('slug');108109 const [service] = await db110 .select()111 .from(serviceOfferings)112 .leftJoin(platforms, eq(serviceOfferings.platformId, platforms.id))113 .leftJoin(categories, eq(serviceOfferings.categoryId, categories.id))114 .leftJoin(users, eq(serviceOfferings.productOwnerCurrentId, users.id))115 .where(and(116 eq(serviceOfferings.slug, slug),117 eq(serviceOfferings.isDeleted, false)118 ))119 .limit(1);120121 if (!service) {122 return c.json({ error: 'Service not found' }, 404);123 }124125 return c.json(service);126});127128export { services as servicesRoutes };Error Handler Middleware
1// api/src/middleware/error-handler.ts2import { Context } from 'hono';3import { HTTPException } from 'hono/http-exception';4import { ZodError } from 'zod';56const isDev = process.env.NODE_ENV !== 'production';78export function errorHandler(err: Error, c: Context) {9 // Zod validation errors10 if (err instanceof ZodError) {11 return c.json({12 error: 'Validation Error',13 details: err.issues.map((issue) => ({14 field: issue.path.join('.'),15 message: issue.message,16 })),17 }, 400);18 }1920 // HTTP exceptions21 if (err instanceof HTTPException) {22 return c.json({ error: err.message }, err.status);23 }2425 // Database connection errors26 if (err.message?.includes('connection')) {27 return c.json({ error: 'Service temporarily unavailable' }, 503);28 }2930 // Generic error31 console.error('Unhandled error:', err);32 return c.json({33 error: 'Internal Server Error',34 message: isDev ? err.message : 'An unexpected error occurred',35 }, 500);36}3738export function notFoundHandler(c: Context) {39 return c.json({ error: 'Not Found' }, 404);40}Rate Limiter Middleware
1// api/src/middleware/rate-limit.ts2import { Context, Next } from 'hono';34interface RateLimitStore {5 [key: string]: { count: number; resetAt: number };6}78const store: RateLimitStore = {};910interface RateLimiterOptions {11 limit: number;12 window: number; // seconds13}1415export function rateLimiter(options: RateLimiterOptions) {16 return async (c: Context, next: Next) => {17 const ip = c.req.header('x-forwarded-for')18 || c.req.header('x-real-ip')19 || c.req.header('cf-connecting-ip')20 || 'unknown';2122 const now = Date.now();23 const windowMs = options.window * 1000;2425 if (!store[ip] || store[ip].resetAt < now) {26 store[ip] = { count: 1, resetAt: now + windowMs };27 } else {28 store[ip].count++;29 }3031 const remaining = Math.max(0, options.limit - store[ip].count);32 c.header('X-RateLimit-Limit', String(options.limit));33 c.header('X-RateLimit-Remaining', String(remaining));34 c.header('X-RateLimit-Reset', String(Math.ceil(store[ip].resetAt / 1000)));3536 if (store[ip].count > options.limit) {37 return c.json({ error: 'Too many requests' }, 429);38 }3940 await next();41 };42}Drizzle ORM Setup
Database Client
1// api/src/db/client.ts2import { drizzle } from 'drizzle-orm/postgres-js';3import postgres from 'postgres';4import * as schema from './schema';56const connectionString = process.env.DATABASE_URL7 || 'postgresql://pgadmin:localdev123@localhost:5432/servicecatalogue';89const client = postgres(connectionString, {10 max: 10, // Connection pool size11 idle_timeout: 20, // Close idle connections after 20s12 connect_timeout: 10,13});1415export const db = drizzle(client, {16 schema,17 logger: process.env.NODE_ENV !== 'production',18});Database Schema
1// api/src/db/schema/service-offerings.ts2import { pgTable, uuid, varchar, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';3import { relations } from 'drizzle-orm';4import { platforms } from './platforms';5import { categories } from './categories';6import { users } from './users';78export const serviceStatusEnum = pgEnum('service_status', [9 'generally_available',10 'restricted',11 'roadmap',12 'deprecated',13 'in_development',14]);1516export const serviceClassEnum = pgEnum('service_class', ['A', 'B', 'C', 'D']);1718export const serviceOfferings = pgTable('service_offerings', {19 id: uuid('id').primaryKey().defaultRandom(),20 name: varchar('name', { length: 255 }).notNull(),21 slug: varchar('slug', { length: 255 }).notNull().unique(),22 version: varchar('version', { length: 50 }),23 status: serviceStatusEnum('status').notNull().default('in_development'),24 serviceClass: serviceClassEnum('service_class'),25 shortDescription: text('short_description'),26 longDescription: text('long_description'),2728 // Relations29 platformId: uuid('platform_id').references(() => platforms.id),30 categoryId: uuid('category_id').references(() => categories.id),31 productOwnerCurrentId: uuid('product_owner_current_id').references(() => users.id),3233 // Soft delete34 isDeleted: boolean('is_deleted').notNull().default(false),35 deletedAt: timestamp('deleted_at'),3637 // Audit38 createdAt: timestamp('created_at').notNull().defaultNow(),39 updatedAt: timestamp('updated_at').notNull().defaultNow(),40});4142export const serviceOfferingsRelations = relations(serviceOfferings, ({ one }) => ({43 platform: one(platforms, {44 fields: [serviceOfferings.platformId],45 references: [platforms.id],46 }),47 category: one(categories, {48 fields: [serviceOfferings.categoryId],49 references: [categories.id],50 }),51 productOwner: one(users, {52 fields: [serviceOfferings.productOwnerCurrentId],53 references: [users.id],54 }),55}));Drizzle Config
1// api/drizzle.config.ts2import { defineConfig } from 'drizzle-kit';34export default defineConfig({5 dialect: 'postgresql',6 schema: './src/db/schema/index.ts',7 out: './drizzle/migrations',8 dbCredentials: {9 url: process.env.DATABASE_URL10 || 'postgresql://pgadmin:localdev123@localhost:5432/servicecatalogue',11 },12 strict: true,13 verbose: true,14});Docker Local Development
docker-compose.yml
1services:2 # PostgreSQL Database3 postgres:4 image: postgres:15-alpine5 container_name: service-catalogue-db6 environment:7 POSTGRES_USER: pgadmin8 POSTGRES_PASSWORD: localdev1239 POSTGRES_DB: servicecatalogue10 ports:11 - "5432:5432"12 volumes:13 - postgres_data:/var/lib/postgresql/data14 healthcheck:15 test: ["CMD-SHELL", "pg_isready -U pgadmin -d servicecatalogue"]16 interval: 5s17 timeout: 5s18 retries: 51920 # Azure Storage Emulator21 azurite:22 image: mcr.microsoft.com/azure-storage/azurite23 container_name: service-catalogue-storage24 ports:25 - "10000:10000" # Blob26 - "10001:10001" # Queue27 - "10002:10002" # Table28 volumes:29 - azurite_data:/data30 command: azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.03132volumes:33 postgres_data:34 azurite_data:Development Workflow
Makefile Commands
1# Makefile2.PHONY: dev setup docker-up docker-down db-migrate db-seed db-studio34# Development5dev:6 npm run dev78setup: docker-up9 npm install10 cd api && npm run db:migrate11 cd api && npm run db:seed1213# Docker14docker-up:15 docker-compose up -d1617docker-down:18 docker-compose down1920docker-reset:21 docker-compose down -v22 docker-compose up -d2324# Database25db-generate:26 cd api && npx drizzle-kit generate2728db-migrate:29 cd api && npx drizzle-kit migrate3031db-push:32 cd api && npx drizzle-kit push --force3334db-seed:35 cd api && npx tsx scripts/seed.ts3637db-studio:38 cd api && npx drizzle-kit studioConfiguration Files
API local.settings.json
1{2 "IsEncrypted": false,3 "Values": {4 "AzureWebJobsStorage": "UseDevelopmentStorage=true",5 "FUNCTIONS_WORKER_RUNTIME": "node",6 "DATABASE_URL": "postgresql://pgadmin:localdev123@localhost:5432/servicecatalogue"7 },8 "Host": {9 "CORS": "http://localhost:5199",10 "CORSCredentials": true,11 "LocalHttpPort": 707112 }13}staticwebapp.config.json
1{2 "routes": [3 {4 "route": "/api/*",5 "allowedRoles": ["authenticated"]6 },7 {8 "route": "/api/health",9 "allowedRoles": ["anonymous"]10 }11 ],12 "navigationFallback": {13 "rewrite": "/index.html",14 "exclude": ["/api/*", "/assets/*", "*.{css,js,svg,png,jpg,ico}"]15 },16 "globalHeaders": {17 "X-Content-Type-Options": "nosniff",18 "X-Frame-Options": "DENY"19 }20}Key Patterns Summary
Frontend Patterns
| Pattern | Implementation |
|---|---|
| Auth Hook | useAuth() returns user, profile, login, logout |
| Data Hook | useServices(params) handles loading, error, refetch |
| API Client | Centralized api.get/post/put/delete with error handling |
| Protected Routes | ProtectedRoute component checks isAuthenticated |
| Path Alias | @/ maps to ./src via Vite config |
Backend Patterns
| Pattern | Implementation |
|---|---|
| Middleware Stack | logger → CORS → security → rate limit |
| Validation | Zod schemas with @hono/zod-validator |
| Error Handling | Centralized handler for Zod, HTTP, DB errors |
| Database Queries | Drizzle ORM with type-safe joins |
| Soft Delete | isDeleted flag for data recovery |
Brisbane/Queensland Context
This architecture powers enterprise internal portals serving Queensland businesses with sub-100ms response times when configured with regional Azure Functions.
Conclusion
This stack combines the best of modern web development:
- Turborepo for monorepo management with parallel builds
- React + Vite for fast SPA development with HMR
- Hono for lightweight, type-safe serverless APIs
- Drizzle ORM for type-safe database operations with migrations
- MSAL for enterprise authentication with Entra ID
- Docker Compose for consistent local development
The patterns shown here scale from internal tools to enterprise portals serving thousands of users.
Need help building your Azure SPA?
Topics
Comments
Sign in to join the conversation
LoginNo comments yet. Be the first to share your thoughts!
Found an issue with this article?
