Azure Static Web Apps & Azure Functions によるサーバーレスアプリケーション構築ガイド
Azure Static Web Apps & Azure Functions によるサーバーレスアプリケーション構築ガイド
はじめに
Microsoft Azure は、エンタープライズグレードのクラウドプラットフォームとして、豊富なサービスと高い信頼性を提供しています。本記事では、Azure Static Web Apps と Azure Functions を組み合わせたモダンなサーバーレスアプリケーションの構築方法を詳しく解説します。
Azure Static Web Apps の概要
Azure Static Web Apps は、静的サイトホスティングとサーバーレス API を統合したサービスです。GitHub Actions との深い統合により、CI/CD パイプラインを自動構築できます。
主な特徴
- 自動 CI/CD: GitHub/Azure DevOps との統合
- グローバル配信: CDN による高速配信
- カスタムドメイン: SSL 証明書の自動管理
- プレビュー環境: PR ごとの自動デプロイ
- 認証統合: AAD、GitHub、Twitter などに対応
プロジェクトのセットアップ
1. Next.js アプリケーションの作成
# プロジェクトの作成
npx create-next-app@latest enhanced-azure-app --typescript --tailwind --app
cd enhanced-azure-app
# 必要なパッケージのインストール
npm install @azure/functions @azure/storage-blob @azure/cosmos
npm install -D @types/node
2. プロジェクト構造
enhanced-azure-app/
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── api/
│ ├── components/
│ └── lib/
├── api/ # Azure Functions
│ ├── GetProducts/
│ ├── CreateOrder/
│ └── host.json
├── next.config.js
├── package.json
└── staticwebapp.config.json
Azure Functions の実装
基本的な Function の作成
// api/GetProducts/index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions"
import { CosmosClient } from "@azure/cosmos"
const cosmosClient = new CosmosClient({
endpoint: process.env.COSMOS_ENDPOINT!,
key: process.env.COSMOS_KEY!,
})
const httpTrigger: AzureFunction = async function (
context: Context,
req: HttpRequest
): Promise<void> {
context.log('GetProducts function processed a request.')
try {
const database = cosmosClient.database('ProductDB')
const container = database.container('Products')
// クエリパラメータの取得
const category = req.query.category || req.body?.category
let querySpec = {
query: "SELECT * FROM c WHERE c.isActive = true",
parameters: [] as any[]
}
if (category) {
querySpec.query += " AND c.category = @category"
querySpec.parameters.push({
name: "@category",
value: category
})
}
// Cosmos DB からデータ取得
const { resources: products } = await container.items
.query(querySpec)
.fetchAll()
context.res = {
status: 200,
headers: {
'Content-Type': 'application/json',
},
body: {
products,
count: products.length,
timestamp: new Date().toISOString()
}
}
} catch (error) {
context.log.error('Error fetching products:', error)
context.res = {
status: 500,
body: {
error: 'Internal Server Error',
message: 'Failed to fetch products'
}
}
}
}
export default httpTrigger
// api/GetProducts/function.json
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req",
"methods": ["get", "post"]
},
{
"type": "http",
"direction": "out",
"name": "res"
}
],
"scriptFile": "../dist/GetProducts/index.js"
}
高度な Function の実装
// api/ProcessOrder/index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions"
import { BlobServiceClient } from "@azure/storage-blob"
import { CosmosClient } from "@azure/cosmos"
import { v4 as uuidv4 } from 'uuid'
interface OrderItem {
productId: string
quantity: number
price: number
}
interface Order {
id?: string
customerId: string
items: OrderItem[]
totalAmount: number
status: 'pending' | 'processing' | 'completed' | 'cancelled'
createdAt?: string
updatedAt?: string
}
const cosmosClient = new CosmosClient({
endpoint: process.env.COSMOS_ENDPOINT!,
key: process.env.COSMOS_KEY!,
})
const blobServiceClient = BlobServiceClient.fromConnectionString(
process.env.STORAGE_CONNECTION_STRING!
)
const httpTrigger: AzureFunction = async function (
context: Context,
req: HttpRequest
): Promise<void> {
// リクエストの検証
if (req.method !== 'POST') {
context.res = {
status: 405,
body: 'Method Not Allowed'
}
return
}
try {
const order: Order = req.body
// 入力検証
if (!order.customerId || !order.items || order.items.length === 0) {
context.res = {
status: 400,
body: {
error: 'Bad Request',
message: 'Invalid order data'
}
}
return
}
// 在庫確認
const inventoryCheck = await checkInventory(order.items)
if (!inventoryCheck.success) {
context.res = {
status: 400,
body: {
error: 'Insufficient Inventory',
items: inventoryCheck.unavailableItems
}
}
return
}
// 注文の作成
const orderId = uuidv4()
const orderData: Order = {
...order,
id: orderId,
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
totalAmount: calculateTotal(order.items)
}
// Cosmos DB に保存
const database = cosmosClient.database('OrderDB')
const container = database.container('Orders')
const { resource: createdOrder } = await container.items.create(orderData)
// 注文確認書を Blob Storage に保存
await saveOrderConfirmation(orderId, orderData)
// 在庫を更新
await updateInventory(order.items)
// イベントをトリガー(Service Bus、Event Grid など)
await triggerOrderProcessing(orderId)
context.res = {
status: 201,
headers: {
'Content-Type': 'application/json',
'Location': `/api/orders/${orderId}`
},
body: {
orderId,
status: 'Order created successfully',
order: createdOrder
}
}
} catch (error) {
context.log.error('Error processing order:', error)
context.res = {
status: 500,
body: {
error: 'Internal Server Error',
message: 'Failed to process order'
}
}
}
}
async function checkInventory(items: OrderItem[]): Promise<{
success: boolean
unavailableItems?: string[]
}> {
const database = cosmosClient.database('ProductDB')
const container = database.container('Inventory')
const unavailableItems: string[] = []
for (const item of items) {
const { resource: inventory } = await container
.item(item.productId, item.productId)
.read()
if (!inventory || inventory.availableQuantity < item.quantity) {
unavailableItems.push(item.productId)
}
}
return {
success: unavailableItems.length === 0,
unavailableItems
}
}
async function updateInventory(items: OrderItem[]): Promise<void> {
const database = cosmosClient.database('ProductDB')
const container = database.container('Inventory')
const promises = items.map(async (item) => {
const { resource: inventory } = await container
.item(item.productId, item.productId)
.read()
if (inventory) {
inventory.availableQuantity -= item.quantity
inventory.lastUpdated = new Date().toISOString()
await container
.item(item.productId, item.productId)
.replace(inventory)
}
})
await Promise.all(promises)
}
async function saveOrderConfirmation(
orderId: string,
order: Order
): Promise<void> {
const containerClient = blobServiceClient
.getContainerClient('order-confirmations')
const blobName = `${orderId}/confirmation.json`
const blockBlobClient = containerClient.getBlockBlobClient(blobName)
const orderConfirmation = {
...order,
confirmationNumber: generateConfirmationNumber(),
generatedAt: new Date().toISOString()
}
await blockBlobClient.upload(
JSON.stringify(orderConfirmation, null, 2),
JSON.stringify(orderConfirmation).length,
{
blobHTTPHeaders: {
blobContentType: 'application/json'
}
}
)
}
function calculateTotal(items: OrderItem[]): number {
return items.reduce((total, item) => {
return total + (item.price * item.quantity)
}, 0)
}
function generateConfirmationNumber(): string {
return `ORD-${Date.now()}-${Math.random().toString(36).substr(2, 9).toUpperCase()}`
}
async function triggerOrderProcessing(orderId: string): Promise<void> {
// Service Bus、Event Grid、またはその他のメッセージングサービスを使用
// ここでは例として Service Bus を使用
const { ServiceBusClient } = require("@azure/service-bus")
const sbClient = new ServiceBusClient(process.env.SERVICE_BUS_CONNECTION_STRING!)
const sender = sbClient.createSender('order-processing-queue')
try {
await sender.sendMessages({
body: {
orderId,
action: 'process_order',
timestamp: new Date().toISOString()
}
})
} finally {
await sender.close()
await sbClient.close()
}
}
export default httpTrigger
フロントエンドの実装
API クライアントの作成
// src/lib/api-client.ts
interface ApiConfig {
baseUrl: string
headers?: Record<string, string>
}
class ApiClient {
private config: ApiConfig
constructor(config: ApiConfig) {
this.config = {
...config,
headers: {
'Content-Type': 'application/json',
...config.headers,
},
}
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.config.baseUrl}${endpoint}`
const response = await fetch(url, {
...options,
headers: {
...this.config.headers,
...options.headers,
},
})
if (!response.ok) {
throw new ApiError(response.status, await response.text())
}
return response.json()
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' })
}
async post<T>(endpoint: string, data: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
})
}
async put<T>(endpoint: string, data: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' })
}
}
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
this.name = 'ApiError'
}
}
// API クライアントのインスタンス作成
const apiClient = new ApiClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL || '/api',
})
// 型定義
export interface Product {
id: string
name: string
description: string
price: number
category: string
imageUrl: string
inStock: boolean
}
export interface Order {
id: string
customerId: string
items: OrderItem[]
totalAmount: number
status: string
createdAt: string
}
export interface OrderItem {
productId: string
quantity: number
price: number
}
// API 関数
export const productApi = {
async getAll(category?: string): Promise<Product[]> {
const params = category ? `?category=${category}` : ''
const response = await apiClient.get<{ products: Product[] }>(
`/products${params}`
)
return response.products
},
async getById(id: string): Promise<Product> {
return apiClient.get<Product>(`/products/${id}`)
},
async create(product: Omit<Product, 'id'>): Promise<Product> {
return apiClient.post<Product>('/products', product)
},
async update(id: string, product: Partial<Product>): Promise<Product> {
return apiClient.put<Product>(`/products/${id}`, product)
},
async delete(id: string): Promise<void> {
return apiClient.delete<void>(`/products/${id}`)
},
}
export const orderApi = {
async create(order: Omit<Order, 'id' | 'createdAt'>): Promise<Order> {
return apiClient.post<Order>('/orders', order)
},
async getById(id: string): Promise<Order> {
return apiClient.get<Order>(`/orders/${id}`)
},
async getByCustomer(customerId: string): Promise<Order[]> {
const response = await apiClient.get<{ orders: Order[] }>(
`/orders?customerId=${customerId}`
)
return response.orders
},
async updateStatus(
id: string,
status: Order['status']
): Promise<Order> {
return apiClient.put<Order>(`/orders/${id}/status`, { status })
},
}
React コンポーネントの実装
// src/app/products/page.tsx
'use client'
import { useState, useEffect } from 'react'
import { productApi, Product } from '@/lib/api-client'
import ProductCard from '@/components/ProductCard'
import LoadingSpinner from '@/components/LoadingSpinner'
import ErrorMessage from '@/components/ErrorMessage'
export default function ProductsPage() {
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedCategory, setSelectedCategory] = useState<string>('')
useEffect(() => {
loadProducts()
}, [selectedCategory])
const loadProducts = async () => {
try {
setLoading(true)
setError(null)
const data = await productApi.getAll(selectedCategory)
setProducts(data)
} catch (err) {
setError('商品の読み込みに失敗しました')
console.error('Error loading products:', err)
} finally {
setLoading(false)
}
}
const categories = ['Electronics', 'Clothing', 'Books', 'Home & Garden']
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">商品一覧</h1>
{/* カテゴリフィルター */}
<div className="mb-6">
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-4 py-2 border rounded-lg"
>
<option value="">すべてのカテゴリ</option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
{/* コンテンツ */}
{loading && <LoadingSpinner />}
{error && <ErrorMessage message={error} onRetry={loadProducts} />}
{!loading && !error && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
{!loading && !error && products.length === 0 && (
<p className="text-center text-gray-500 mt-8">
商品が見つかりませんでした
</p>
)}
</div>
)
}
// src/components/ProductCard.tsx
import Image from 'next/image'
import Link from 'next/link'
import { Product } from '@/lib/api-client'
interface ProductCardProps {
product: Product
}
export default function ProductCard({ product }: ProductCardProps) {
return (
<Link href={`/products/${product.id}`}>
<div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
<div className="relative h-48">
<Image
src={product.imageUrl}
alt={product.name}
fill
className="object-cover"
/>
{!product.inStock && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<span className="text-white font-bold">在庫切れ</span>
</div>
)}
</div>
<div className="p-4">
<h3 className="font-semibold text-lg mb-2">{product.name}</h3>
<p className="text-gray-600 text-sm mb-2 line-clamp-2">
{product.description}
</p>
<div className="flex justify-between items-center">
<span className="text-xl font-bold text-blue-600">
¥{product.price.toLocaleString()}
</span>
<span className="text-sm text-gray-500">{product.category}</span>
</div>
</div>
</div>
</Link>
)
}
Azure へのデプロイ
staticwebapp.config.json の設定
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/api/*", "/_next/*", "/images/*"]
},
"routes": [
{
"route": "/api/*",
"allowedRoles": ["anonymous"]
},
{
"route": "/admin/*",
"allowedRoles": ["authenticated", "admin"]
}
],
"responseOverrides": {
"404": {
"rewrite": "/404.html",
"statusCode": 404
}
},
"globalHeaders": {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()"
},
"mimeTypes": {
".json": "application/json",
".js": "application/javascript",
".mjs": "application/javascript"
},
"platform": {
"apiRuntime": "node:16"
}
}
GitHub Actions ワークフロー
# .github/workflows/azure-static-web-apps.yml
name: Azure Static Web Apps CI/CD
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened, closed]
branches:
- main
jobs:
build_and_deploy_job:
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
name: Build and Deploy Job
steps:
- uses: actions/checkout@v3
with:
submodules: true
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
env:
CI: true
- name: Build application
run: npm run build
env:
NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }}
- name: Build And Deploy
id: builddeploy
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
app_location: "/"
api_location: "api"
output_location: "out"
app_build_command: "npm run build && npm run export"
api_build_command: "npm run build:api"
env:
COSMOS_ENDPOINT: ${{ secrets.COSMOS_ENDPOINT }}
COSMOS_KEY: ${{ secrets.COSMOS_KEY }}
STORAGE_CONNECTION_STRING: ${{ secrets.STORAGE_CONNECTION_STRING }}
SERVICE_BUS_CONNECTION_STRING: ${{ secrets.SERVICE_BUS_CONNECTION_STRING }}
close_pull_request_job:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
name: Close Pull Request Job
steps:
- name: Close Pull Request
id: closepullrequest
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: "close"
モニタリングとログ
Application Insights の統合
// src/lib/monitoring.ts
import { ApplicationInsights } from '@microsoft/applicationinsights-web'
import { ReactPlugin } from '@microsoft/applicationinsights-react-js'
let appInsights: ApplicationInsights | null = null
let reactPlugin: ReactPlugin | null = null
export const initializeMonitoring = () => {
if (typeof window === 'undefined' || appInsights) return
reactPlugin = new ReactPlugin()
appInsights = new ApplicationInsights({
config: {
connectionString: process.env.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING,
extensions: [reactPlugin],
enableAutoRouteTracking: true,
disableFetchTracking: false,
enableCorsCorrelation: true,
enableRequestHeaderTracking: true,
enableResponseHeaderTracking: true,
correlationHeaderExcludedDomains: ['localhost'],
disableAjaxTracking: false,
autoTrackPageVisitTime: true,
enableUnhandledPromiseRejectionTracking: true,
},
})
appInsights.loadAppInsights()
// カスタムプロパティの設定
appInsights.addTelemetryInitializer((envelope) => {
envelope.tags = envelope.tags || {}
envelope.data = envelope.data || {}
// 環境情報の追加
envelope.tags['ai.cloud.role'] = 'frontend'
envelope.data['environment'] = process.env.NODE_ENV
return true
})
}
// カスタムイベントのトラッキング
export const trackEvent = (name: string, properties?: Record<string, any>) => {
if (!appInsights) return
appInsights.trackEvent({
name,
properties: {
...properties,
timestamp: new Date().toISOString(),
},
})
}
// エラートラッキング
export const trackException = (error: Error, severityLevel?: number) => {
if (!appInsights) return
appInsights.trackException({
error,
severityLevel: severityLevel || 3, // Error level
})
}
// パフォーマンス測定
export const trackMetric = (
name: string,
average: number,
properties?: Record<string, any>
) => {
if (!appInsights) return
appInsights.trackMetric({
name,
average,
properties,
})
}
// React エラーバウンダリー用フック
export const useErrorHandler = () => {
return (error: Error, errorInfo: React.ErrorInfo) => {
trackException(error, 3)
// 追加情報の記録
trackEvent('React Error Boundary', {
componentStack: errorInfo.componentStack,
errorMessage: error.message,
errorStack: error.stack,
})
}
}
// src/app/layout.tsx
'use client'
import { useEffect } from 'react'
import { initializeMonitoring } from '@/lib/monitoring'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
useEffect(() => {
initializeMonitoring()
}, [])
return (
<html lang="ja">
<body>{children}</body>
</html>
)
}
Function のロギング
// api/shared/logger.ts
import { Context } from "@azure/functions"
export interface Logger {
info(message: string, ...args: any[]): void
warn(message: string, ...args: any[]): void
error(message: string, error?: Error, ...args: any[]): void
metric(name: string, value: number, properties?: Record<string, any>): void
}
export class AzureLogger implements Logger {
constructor(private context: Context) {}
info(message: string, ...args: any[]): void {
this.context.log.info(message, ...args)
// Application Insights にも送信
if (this.context.bindings.telemetry) {
this.context.bindings.telemetry.push({
name: 'CustomLog',
properties: {
level: 'INFO',
message,
args,
timestamp: new Date().toISOString(),
},
})
}
}
warn(message: string, ...args: any[]): void {
this.context.log.warn(message, ...args)
if (this.context.bindings.telemetry) {
this.context.bindings.telemetry.push({
name: 'CustomLog',
properties: {
level: 'WARN',
message,
args,
timestamp: new Date().toISOString(),
},
})
}
}
error(message: string, error?: Error, ...args: any[]): void {
this.context.log.error(message, error, ...args)
if (this.context.bindings.telemetry) {
this.context.bindings.telemetry.push({
name: 'CustomException',
properties: {
level: 'ERROR',
message,
error: error ? {
name: error.name,
message: error.message,
stack: error.stack,
} : null,
args,
timestamp: new Date().toISOString(),
},
})
}
}
metric(name: string, value: number, properties?: Record<string, any>): void {
if (this.context.bindings.telemetry) {
this.context.bindings.telemetry.push({
name: 'CustomMetric',
properties: {
metricName: name,
value,
...properties,
timestamp: new Date().toISOString(),
},
})
}
}
}
// 使用例
const httpTrigger: AzureFunction = async function (
context: Context,
req: HttpRequest
): Promise<void> {
const logger = new AzureLogger(context)
const startTime = Date.now()
try {
logger.info('Processing request', {
method: req.method,
url: req.url,
headers: req.headers,
})
// 処理...
const duration = Date.now() - startTime
logger.metric('RequestDuration', duration, {
endpoint: req.url,
method: req.method,
})
} catch (error) {
logger.error('Request processing failed', error as Error)
throw error
}
}
セキュリティのベストプラクティス
認証と認可
// src/lib/auth.ts
import { PublicClientApplication } from '@azure/msal-browser'
import { msalConfig } from './auth-config'
class AuthService {
private msalInstance: PublicClientApplication
constructor() {
this.msalInstance = new PublicClientApplication(msalConfig)
}
async login(): Promise<void> {
try {
const loginResponse = await this.msalInstance.loginPopup({
scopes: ['openid', 'profile', 'email', 'api://your-api-id/access_as_user'],
})
if (loginResponse) {
this.msalInstance.setActiveAccount(loginResponse.account)
}
} catch (error) {
console.error('Login failed:', error)
throw error
}
}
async logout(): Promise<void> {
await this.msalInstance.logoutPopup()
}
async getToken(): Promise<string | null> {
const account = this.msalInstance.getActiveAccount()
if (!account) {
throw new Error('No active account')
}
try {
const response = await this.msalInstance.acquireTokenSilent({
scopes: ['api://your-api-id/access_as_user'],
account,
})
return response.accessToken
} catch (error) {
// トークンの更新が必要な場合
const response = await this.msalInstance.acquireTokenPopup({
scopes: ['api://your-api-id/access_as_user'],
})
return response.accessToken
}
}
isAuthenticated(): boolean {
return this.msalInstance.getActiveAccount() !== null
}
getAccount() {
return this.msalInstance.getActiveAccount()
}
}
export const authService = new AuthService()
// API クライアントでの認証トークン使用
export class AuthenticatedApiClient extends ApiClient {
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await authService.getToken()
const headers = {
...options.headers,
Authorization: token ? `Bearer ${token}` : '',
}
return super.request<T>(endpoint, {
...options,
headers,
})
}
}
環境変数の管理
// src/lib/config.ts
interface Config {
api: {
baseUrl: string
timeout: number
}
auth: {
clientId: string
authority: string
redirectUri: string
}
storage: {
accountName: string
containerName: string
}
monitoring: {
connectionString: string
enabled: boolean
}
}
const getConfig = (): Config => {
// 環境変数の検証
const requiredEnvVars = [
'NEXT_PUBLIC_API_URL',
'NEXT_PUBLIC_AUTH_CLIENT_ID',
'NEXT_PUBLIC_AUTH_AUTHORITY',
]
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
throw new Error(`Missing required environment variable: ${envVar}`)
}
}
return {
api: {
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
timeout: parseInt(process.env.NEXT_PUBLIC_API_TIMEOUT || '30000'),
},
auth: {
clientId: process.env.NEXT_PUBLIC_AUTH_CLIENT_ID!,
authority: process.env.NEXT_PUBLIC_AUTH_AUTHORITY!,
redirectUri: process.env.NEXT_PUBLIC_AUTH_REDIRECT_URI || window.location.origin,
},
storage: {
accountName: process.env.NEXT_PUBLIC_STORAGE_ACCOUNT!,
containerName: process.env.NEXT_PUBLIC_STORAGE_CONTAINER!,
},
monitoring: {
connectionString: process.env.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING!,
enabled: process.env.NEXT_PUBLIC_MONITORING_ENABLED === 'true',
},
}
}
export const config = getConfig()
パフォーマンス最適化
CDN とキャッシング戦略
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
// 画像最適化
images: {
loader: 'custom',
loaderFile: './src/lib/image-loader.ts',
domains: ['yourstorageaccount.blob.core.windows.net'],
},
// ヘッダー設定
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
source: '/api/:path*',
headers: [
{
key: 'Cache-Control',
value: 'no-store, must-revalidate',
},
],
},
]
},
// 環境変数の型安全性
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
},
}
module.exports = nextConfig
// src/lib/image-loader.ts
export default function azureImageLoader({
src,
width,
quality,
}: {
src: string
width: number
quality?: number
}) {
// Azure CDN の画像変換パラメータ
const params = new URLSearchParams({
w: width.toString(),
q: (quality || 75).toString(),
fm: 'webp',
fit: 'cover',
})
// CDN URL の構築
const cdnUrl = process.env.NEXT_PUBLIC_CDN_URL || 'https://yourcdn.azureedge.net'
return `${cdnUrl}${src}?${params.toString()}`
}
まとめ
Azure Static Web Apps と Azure Functions を組み合わせることで、スケーラブルで高性能なサーバーレスアプリケーションを構築できます。
重要なポイント:
- GitHub Actions との統合による自動 CI/CD
- Azure Functions でのサーバーレス API 実装
- Application Insights による包括的なモニタリング
- Azure AD による認証・認可
- CDN を活用したグローバル配信
エンハンスド株式会社では、Azure を活用したエンタープライズアプリケーション開発を支援しています。お気軽にお問い合わせください。
タグ: #Azure #StaticWebApps #AzureFunctions #Serverless #NextJS #TypeScript
執筆者: エンハンスド株式会社 クラウドソリューション部
公開日: 2024年12月20日