Skip to main content
/ DevOps

Azure Static Web Apps with Hono: The Complete 2026 Guide

Sacha Roussakis-NotterSacha Roussakis-Notter
25 min read
AzureAzure
Hono
Docker
TypeScript
React
PostgreSQL
Share

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

Before You Begin
0/5
0% completebuun.group
Install Azure Functions Core Tools
brew tap azure/functions
brew install azure-functions-core-tools@4
3 optionsbuun.group

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

flowchart

/api/*

Users

Azure CDN

SWA Gateway

React SPA

Hono API

Auth Middleware

API Routes

Drizzle ORM

PostgreSQL

Entra ID

Ctrl+scroll to zoom • Drag to pan32%

Technology Stack

LayerTechnologyPurpose
MonorepoTurborepoParallel builds, workspace management
FrontendReact + Vite + TailwindSPA with hot reload, utility-first CSS
RoutingReact Router v6Client-side navigation
API FrameworkHonoLightweight, TypeScript-first (14KB)
ValidationZod + @hono/zod-validatorType-safe request validation
DatabaseDrizzle ORM + PostgreSQLType-safe queries, migrations
AuthMSAL React + Entra IDEnterprise SSO with MFA
Local DevDocker ComposePostgreSQL + Azurite emulation

Project Structure

This is a Turborepo monorepo with frontend and API workspaces:

Monorepo Structure
project/
project/
frontend/// React SPA
src/
components/
auth/
ProtectedRoute.tsx
layout/
AppShell.tsx
Header.tsx
Sidebar.tsx
ui/
config/
auth.ts
hooks/
useAuth.ts
useServices.ts
lib/
api/
client.ts
types.ts
pages/
App.tsx
main.tsx
public/
staticwebapp.config.json
package.json
vite.config.ts
api/// Hono Azure Functions
src/
db/
client.ts
schema/
index.ts
service-offerings.ts
middleware/
cors.ts
security.ts
rate-limit.ts
error-handler.ts
routes/
health.ts
services.ts
index.ts
drizzle/
migrations/
drizzle.config.ts
host.json
local.settings.json
package.json
docker-compose.yml
Makefile
turbo.json
package.json
1 item at rootbuun.group

Turborepo Configuration

Root package.json

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

json
1{
2 "$schema": "https://turbo.build/schema.json",
3 "tasks": {
4 "dev": {
5 "cache": false,
6 "persistent": true
7 },
8 "build": {
9 "dependsOn": ["^build"],
10 "outputs": ["dist/**", "build/**"]
11 },
12 "lint": {
13 "dependsOn": ["^lint"]
14 }
15 }
16}

Frontend Setup

Vite Configuration

typescript
1// frontend/vite.config.ts
2import { defineConfig } from 'vite';
3import react from '@vitejs/plugin-react';
4import path from 'path';
5
6export 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

typescript
1// frontend/src/config/auth.ts
2import { Configuration, LogLevel } from '@azure/msal-browser';
3
4export 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 localStorage
14 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};
27
28export const loginRequest = {
29 scopes: ['openid', 'profile', 'email'],
30};

Custom Auth Hook

typescript
1// frontend/src/hooks/useAuth.ts
2import { useMsal, useAccount, useIsAuthenticated } from '@azure/msal-react';
3import { useMemo, useCallback } from 'react';
4import { loginRequest } from '@/config/auth';
5
6interface 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}
16
17export function useAuth() {
18 const { instance, accounts, inProgress } = useMsal();
19 const account = useAccount(accounts[0] || null);
20 const isAuthenticated = useIsAuthenticated();
21
22 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]);
30
31 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]);
45
46 const login = useCallback(async () => {
47 await instance.loginRedirect(loginRequest);
48 }, [instance]);
49
50 const logout = useCallback(async () => {
51 await instance.logoutRedirect({
52 postLogoutRedirectUri: window.location.origin,
53 });
54 }, [instance]);
55
56 return {
57 user,
58 profile,
59 isAuthenticated,
60 isLoading: inProgress !== 'none',
61 login,
62 logout,
63 };
64}

API Client

typescript
1// frontend/src/lib/api/client.ts
2export class ApiError extends Error {
3 constructor(
4 public status: number,
5 message: string
6 ) {
7 super(message);
8 this.name = 'ApiError';
9 }
10}
11
12async function request<T>(
13 endpoint: string,
14 options: RequestInit = {}
15): Promise<T> {
16 const url = `/api${endpoint}`;
17
18 const response = await fetch(url, {
19 ...options,
20 headers: {
21 'Content-Type': 'application/json',
22 ...options.headers,
23 },
24 });
25
26 if (!response.ok) {
27 const error = await response.json().catch(() => ({}));
28 throw new ApiError(response.status, error.message || 'Request failed');
29 }
30
31 return response.json();
32}
33
34export 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

typescript
1// frontend/src/hooks/useServices.ts
2import { useState, useEffect, useCallback } from 'react';
3import { api } from '@/lib/api/client';
4
5interface 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}
14
15interface UseServicesParams {
16 page?: number;
17 limit?: number;
18 search?: string;
19 categoryId?: string;
20 sortBy?: string;
21}
22
23export 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);
28
29 const fetchServices = useCallback(async () => {
30 setIsLoading(true);
31 setError(null);
32
33 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);
40
41 const query = queryParams.toString();
42 const endpoint = query ? `/services?${query}` : '/services';
43
44 const response = await api.get<{
45 data: Service[];
46 pagination: typeof pagination;
47 }>(endpoint);
48
49 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]);
57
58 useEffect(() => {
59 fetchServices();
60 }, [fetchServices]);
61
62 return { services, pagination, isLoading, error, refetch: fetchServices };
63}

Hono API Setup

Main Entry Point

typescript
1// api/src/index.ts
2import { Hono } from 'hono';
3import { logger } from 'hono/logger';
4import { cors } from 'hono/cors';
5import { secureHeaders } from 'hono/secure-headers';
6
7import { 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';
12
13const api = new Hono().basePath('/api');
14
15// Middleware stack (order matters)
16api.use('*', logger());
17api.use('*', cors({
18 origin: (origin) => {
19 // Allow localhost in development
20 if (origin?.includes('localhost') || origin?.includes('127.0.0.1')) {
21 return origin;
22 }
23 // Production origins
24 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 }));
36
37// Routes
38api.route('/health', healthRoutes);
39api.route('/services', servicesRoutes);
40api.route('/reference', referenceRoutes);
41
42// Error handling
43api.onError(errorHandler);
44api.notFound(notFoundHandler);
45
46export default api;

Azure Functions Entry

typescript
1// api/src/functions/httpTrigger.ts
2import { app } from '@azure/functions';
3import { azureHonoHandler } from '@marplex/hono-azurefunc-adapter';
4import honoApp from '../index';
5
6app.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

typescript
1// api/src/routes/services.ts
2import { 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';
8
9const 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});
19
20const services = new Hono();
21
22// GET /api/services - List with filters
23services.get(
24 '/',
25 zValidator('query', listQuerySchema),
26 async (c) => {
27 const query = c.req.valid('query');
28 const offset = (query.page - 1) * query.limit;
29
30 // Build dynamic where conditions
31 const conditions = [eq(serviceOfferings.isDeleted, false)];
32
33 if (query.search) {
34 conditions.push(
35 or(
36 ilike(serviceOfferings.name, `%${query.search}%`),
37 ilike(serviceOfferings.shortDescription, `%${query.search}%`)
38 )!
39 );
40 }
41
42 if (query.status) {
43 conditions.push(eq(serviceOfferings.status, query.status));
44 }
45
46 if (query.categoryId) {
47 conditions.push(eq(serviceOfferings.categoryId, query.categoryId));
48 }
49
50 if (query.platformId) {
51 conditions.push(eq(serviceOfferings.platformId, query.platformId));
52 }
53
54 // Sort direction
55 const sortFn = query.sortOrder === 'desc' ? desc : asc;
56 const sortColumn = serviceOfferings[query.sortBy as keyof typeof serviceOfferings] || serviceOfferings.name;
57
58 // Execute queries in parallel
59 const [items, countResult] = await Promise.all([
60 db
61 .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),
84
85 db
86 .select({ count: sql<number>`count(*)::int` })
87 .from(serviceOfferings)
88 .where(and(...conditions)),
89 ]);
90
91 const total = countResult[0]?.count || 0;
92
93 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);
104
105// GET /api/services/:slug - Single service
106services.get('/:slug', async (c) => {
107 const slug = c.req.param('slug');
108
109 const [service] = await db
110 .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);
120
121 if (!service) {
122 return c.json({ error: 'Service not found' }, 404);
123 }
124
125 return c.json(service);
126});
127
128export { services as servicesRoutes };

Error Handler Middleware

typescript
1// api/src/middleware/error-handler.ts
2import { Context } from 'hono';
3import { HTTPException } from 'hono/http-exception';
4import { ZodError } from 'zod';
5
6const isDev = process.env.NODE_ENV !== 'production';
7
8export function errorHandler(err: Error, c: Context) {
9 // Zod validation errors
10 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 }
19
20 // HTTP exceptions
21 if (err instanceof HTTPException) {
22 return c.json({ error: err.message }, err.status);
23 }
24
25 // Database connection errors
26 if (err.message?.includes('connection')) {
27 return c.json({ error: 'Service temporarily unavailable' }, 503);
28 }
29
30 // Generic error
31 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}
37
38export function notFoundHandler(c: Context) {
39 return c.json({ error: 'Not Found' }, 404);
40}

Rate Limiter Middleware

typescript
1// api/src/middleware/rate-limit.ts
2import { Context, Next } from 'hono';
3
4interface RateLimitStore {
5 [key: string]: { count: number; resetAt: number };
6}
7
8const store: RateLimitStore = {};
9
10interface RateLimiterOptions {
11 limit: number;
12 window: number; // seconds
13}
14
15export 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';
21
22 const now = Date.now();
23 const windowMs = options.window * 1000;
24
25 if (!store[ip] || store[ip].resetAt < now) {
26 store[ip] = { count: 1, resetAt: now + windowMs };
27 } else {
28 store[ip].count++;
29 }
30
31 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)));
35
36 if (store[ip].count > options.limit) {
37 return c.json({ error: 'Too many requests' }, 429);
38 }
39
40 await next();
41 };
42}

Drizzle ORM Setup

Database Client

typescript
1// api/src/db/client.ts
2import { drizzle } from 'drizzle-orm/postgres-js';
3import postgres from 'postgres';
4import * as schema from './schema';
5
6const connectionString = process.env.DATABASE_URL
7 || 'postgresql://pgadmin:localdev123@localhost:5432/servicecatalogue';
8
9const client = postgres(connectionString, {
10 max: 10, // Connection pool size
11 idle_timeout: 20, // Close idle connections after 20s
12 connect_timeout: 10,
13});
14
15export const db = drizzle(client, {
16 schema,
17 logger: process.env.NODE_ENV !== 'production',
18});

Database Schema

typescript
1// api/src/db/schema/service-offerings.ts
2import { 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';
7
8export const serviceStatusEnum = pgEnum('service_status', [
9 'generally_available',
10 'restricted',
11 'roadmap',
12 'deprecated',
13 'in_development',
14]);
15
16export const serviceClassEnum = pgEnum('service_class', ['A', 'B', 'C', 'D']);
17
18export 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'),
27
28 // Relations
29 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),
32
33 // Soft delete
34 isDeleted: boolean('is_deleted').notNull().default(false),
35 deletedAt: timestamp('deleted_at'),
36
37 // Audit
38 createdAt: timestamp('created_at').notNull().defaultNow(),
39 updatedAt: timestamp('updated_at').notNull().defaultNow(),
40});
41
42export 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

typescript
1// api/drizzle.config.ts
2import { defineConfig } from 'drizzle-kit';
3
4export default defineConfig({
5 dialect: 'postgresql',
6 schema: './src/db/schema/index.ts',
7 out: './drizzle/migrations',
8 dbCredentials: {
9 url: process.env.DATABASE_URL
10 || 'postgresql://pgadmin:localdev123@localhost:5432/servicecatalogue',
11 },
12 strict: true,
13 verbose: true,
14});

Docker Local Development

docker-compose.yml

yaml
1services:
2 # PostgreSQL Database
3 postgres:
4 image: postgres:15-alpine
5 container_name: service-catalogue-db
6 environment:
7 POSTGRES_USER: pgadmin
8 POSTGRES_PASSWORD: localdev123
9 POSTGRES_DB: servicecatalogue
10 ports:
11 - "5432:5432"
12 volumes:
13 - postgres_data:/var/lib/postgresql/data
14 healthcheck:
15 test: ["CMD-SHELL", "pg_isready -U pgadmin -d servicecatalogue"]
16 interval: 5s
17 timeout: 5s
18 retries: 5
19
20 # Azure Storage Emulator
21 azurite:
22 image: mcr.microsoft.com/azure-storage/azurite
23 container_name: service-catalogue-storage
24 ports:
25 - "10000:10000" # Blob
26 - "10001:10001" # Queue
27 - "10002:10002" # Table
28 volumes:
29 - azurite_data:/data
30 command: azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0
31
32volumes:
33 postgres_data:
34 azurite_data:

Development Workflow

flowchart

Development Servers

Docker Services

/api proxy

PostgreSQL :5432

Azurite :10000

Vite :5199

Azure Functions :7071

Developer

Ctrl+scroll to zoom • Drag to pan49%

Makefile Commands

makefile
1# Makefile
2.PHONY: dev setup docker-up docker-down db-migrate db-seed db-studio
3
4# Development
5dev:
6 npm run dev
7
8setup: docker-up
9 npm install
10 cd api && npm run db:migrate
11 cd api && npm run db:seed
12
13# Docker
14docker-up:
15 docker-compose up -d
16
17docker-down:
18 docker-compose down
19
20docker-reset:
21 docker-compose down -v
22 docker-compose up -d
23
24# Database
25db-generate:
26 cd api && npx drizzle-kit generate
27
28db-migrate:
29 cd api && npx drizzle-kit migrate
30
31db-push:
32 cd api && npx drizzle-kit push --force
33
34db-seed:
35 cd api && npx tsx scripts/seed.ts
36
37db-studio:
38 cd api && npx drizzle-kit studio
Development Commands
# First-time setup
$make setup
# Start development
$make dev
# Open Drizzle Studio GUI
$make db-studio
3 commandsbuun.group

Configuration Files

API local.settings.json

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": 7071
12 }
13}

staticwebapp.config.json

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

PatternImplementation
Auth HookuseAuth() returns user, profile, login, logout
Data HookuseServices(params) handles loading, error, refetch
API ClientCentralized api.get/post/put/delete with error handling
Protected RoutesProtectedRoute component checks isAuthenticated
Path Alias@/ maps to ./src via Vite config

Backend Patterns

PatternImplementation
Middleware Stacklogger → CORS → security → rate limit
ValidationZod schemas with @hono/zod-validator
Error HandlingCentralized handler for Zod, HTTP, DB errors
Database QueriesDrizzle ORM with type-safe joins
Soft DeleteisDeleted 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

Azure Static Web AppsHono frameworkDrizzle ORMAzure FunctionsDocker local developmentEntra ID SSOMSAL ReactTurborepo monorepo

Share this post

Share

Comments

Sign in to join the conversation

Login

No comments yet. Be the first to share your thoughts!

Found an issue with this article?

/ Let's Talk

Want to work with us?

Whether you need help with architecture, development, or technical consulting, our team is here to help bring your vision to life.