Skip to content

GitHub Static Web Apps with Azure Images

This guide demonstrates how to seamlessly integrate Azure Blob Storage images into your GitHub Static Web Apps, including automated deployment workflows and performance optimizations.

Before starting, ensure you have:

  • GitHub account with a repository
  • Azure subscription with an active Storage Account (see Azure Blob Storage for Images)
  • Images already uploaded to Azure Blob Storage
  • Basic knowledge of HTML, CSS, and JavaScript

🚀 Step 1: Set Up GitHub Static Web Apps

Section titled “🚀 Step 1: Set Up GitHub Static Web Apps”

Using Azure CLI:

Terminal window
# Variables
RESOURCE_GROUP="rg-static-webapp"
WEBAPP_NAME="my-image-gallery"
GITHUB_REPO="https://github.com/yourusername/your-repo"
LOCATION="East US 2"
# Create resource group if it doesn't exist
az group create \
--name $RESOURCE_GROUP \
--location "$LOCATION"
# Create Static Web App
az staticwebapp create \
--name $WEBAPP_NAME \
--resource-group $RESOURCE_GROUP \
--source $GITHUB_REPO \
--location "$LOCATION" \
--branch main \
--app-location "/" \
--api-location "api" \
--output-location "dist"
  1. Go to Azure Portal
  2. Search for “Static Web Apps” and click Create
  3. Fill in the details:
    • Subscription: Select your subscription
    • Resource Group: Create new or select existing
    • Name: Choose a unique name
    • Plan type: Free (for most use cases)
    • Region: Select closest to your users
    • Source: GitHub
    • Organization: Your GitHub username/org
    • Repository: Your repository name
    • Branch: main (or your default branch)

🔗 Step 2: Configure Image URLs in Your Application

Section titled “🔗 Step 2: Configure Image URLs in Your Application”

Create a .env file in your project root:

Terminal window
# Azure Storage Configuration
AZURE_STORAGE_ACCOUNT_NAME=myimagestore1234
AZURE_STORAGE_CONTAINER_NAME=images
AZURE_STORAGE_BASE_URL=https://myimagestore1234.blob.core.windows.net/images

Create src/config/azure.js:

// Azure Storage Configuration
const azureConfig = {
storageAccountName: process.env.AZURE_STORAGE_ACCOUNT_NAME || 'myimagestore1234',
containerName: process.env.AZURE_STORAGE_CONTAINER_NAME || 'images',
baseUrl: process.env.AZURE_STORAGE_BASE_URL || 'https://myimagestore1234.blob.core.windows.net/images'
};
// Helper function to generate image URLs
export function getImageUrl(imageName, options = {}) {
const { width, height, format = 'webp' } = options;
let url = `${azureConfig.baseUrl}/${imageName}`;
// Add query parameters for image transformations if using Azure CDN
const params = new URLSearchParams();
if (width) params.append('w', width.toString());
if (height) params.append('h', height.toString());
if (format !== 'original') params.append('format', format);
if (params.toString()) {
url += `?${params.toString()}`;
}
return url;
}
// Helper for responsive images
export function generateResponsiveImageSet(imageName, sizes = [400, 800, 1200]) {
return sizes.map(size => ({
src: getImageUrl(imageName, { width: size }),
width: size
}));
}
export default azureConfig;

🖼️ Step 3: Implement Image Components

Section titled “🖼️ Step 3: Implement Image Components”
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Image Gallery</title>
</head>
<body>
<div class="image-gallery">
<!-- Single image -->
<img src="https://myimagestore1234.blob.core.windows.net/images/hero-image.jpg"
alt="Hero Image"
class="hero-image">
<!-- Responsive image with srcset -->
<img src="https://myimagestore1234.blob.core.windows.net/images/gallery-1.jpg"
srcset="https://myimagestore1234.blob.core.windows.net/images/gallery-1.jpg 400w,
https://myimagestore1234.blob.core.windows.net/images/gallery-1.jpg 800w,
https://myimagestore1234.blob.core.windows.net/images/gallery-1.jpg 1200w"
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
alt="Gallery Image 1">
</div>
</body>
</html>
import React, { useState, useEffect } from 'react';
import { getImageUrl, generateResponsiveImageSet } from '../config/azure';
const ImageGallery = ({ images }) => {
const [loading, setLoading] = useState(true);
const [imageErrors, setImageErrors] = useState(new Set());
const handleImageError = (imageName) => {
setImageErrors(prev => new Set([...prev, imageName]));
};
const handleImageLoad = () => {
setLoading(false);
};
return (
<div className="image-gallery">
{images.map((imageName, index) => {
if (imageErrors.has(imageName)) {
return (
<div key={index} className="image-placeholder">
<p>Image not available</p>
</div>
);
}
const responsiveSet = generateResponsiveImageSet(imageName);
return (
<picture key={index} className="gallery-image">
<source
srcSet={responsiveSet.map(img => `${img.src} ${img.width}w`).join(', ')}
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
type="image/webp"
/>
<img
src={getImageUrl(imageName)}
alt={`Gallery image ${index + 1}`}
loading="lazy"
onError={() => handleImageError(imageName)}
onLoad={handleImageLoad}
className="responsive-image"
/>
</picture>
);
})}
</div>
);
};
// Usage component
const App = () => {
const galleryImages = [
'hero-banner.jpg',
'product-1.jpg',
'product-2.jpg',
'team-photo.jpg'
];
return (
<div className="app">
<h1>My Azure Image Gallery</h1>
<ImageGallery images={galleryImages} />
</div>
);
};
export default App;
<template>
<div class="image-gallery">
<div v-for="(image, index) in images" :key="index" class="gallery-item">
<img
:src="getImageUrl(image.name)"
:alt="image.alt"
:loading="index > 2 ? 'lazy' : 'eager'"
@error="handleImageError(image.name)"
@load="handleImageLoad"
class="gallery-image"
/>
</div>
</div>
</template>
<script>
import { getImageUrl } from '../config/azure.js';
export default {
name: 'ImageGallery',
props: {
images: {
type: Array,
required: true
}
},
methods: {
getImageUrl(imageName) {
return getImageUrl(imageName, { width: 800 });
},
handleImageError(imageName) {
console.error(`Failed to load image: ${imageName}`);
},
handleImageLoad() {
console.log('Image loaded successfully');
}
}
};
</script>

🔄 Step 4: Set Up GitHub Actions Workflow

Section titled “🔄 Step 4: Set Up GitHub Actions Workflow”

Create .github/workflows/azure-static-web-apps-deploy.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 install
- name: Build application
run: npm run build
env:
AZURE_STORAGE_ACCOUNT_NAME: ${{ secrets.AZURE_STORAGE_ACCOUNT_NAME }}
AZURE_STORAGE_CONTAINER_NAME: ${{ secrets.AZURE_STORAGE_CONTAINER_NAME }}
AZURE_STORAGE_BASE_URL: ${{ secrets.AZURE_STORAGE_BASE_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: "/" # App source code path
api_location: "api" # Api source code path - optional
output_location: "dist" # Built app content directory - optional
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"

In your GitHub repository, go to Settings > Secrets and variables > Actions and add:

  1. AZURE_STATIC_WEB_APPS_API_TOKEN

    • Get this from Azure Portal > Your Static Web App > Manage deployment token
  2. AZURE_STORAGE_ACCOUNT_NAME

    • Your Azure Storage Account name
  3. AZURE_STORAGE_CONTAINER_NAME

    • Container name (e.g., “images”)
  4. AZURE_STORAGE_BASE_URL

5.2 Using Azure CLI to Get Deployment Token

Section titled “5.2 Using Azure CLI to Get Deployment Token”
Terminal window
# Get the deployment token for your Static Web App
az staticwebapp secrets list \
--name $WEBAPP_NAME \
--resource-group $RESOURCE_GROUP \
--query "properties.apiKey" \
--output tsv
Terminal window
# Create CDN profile
az cdn profile create \
--resource-group $RESOURCE_GROUP \
--name "mycdnprofile" \
--sku Standard_Microsoft
# Create CDN endpoint for storage account
az cdn endpoint create \
--resource-group $RESOURCE_GROUP \
--profile-name "mycdnprofile" \
--name "mycdn-images" \
--origin myimagestore1234.blob.core.windows.net \
--origin-host-header myimagestore1234.blob.core.windows.net
// Modern lazy loading with Intersection Observer
class LazyImageLoader {
constructor() {
this.imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img);
}
});
});
}
observe(selector = 'img[data-src]') {
const lazyImages = document.querySelectorAll(selector);
lazyImages.forEach(img => {
this.imageObserver.observe(img);
});
}
}
// Initialize lazy loading
document.addEventListener('DOMContentLoaded', () => {
const lazyLoader = new LazyImageLoader();
lazyLoader.observe();
});

Update your staticwebapp.config.json:

{
"globalHeaders": {
"Cache-Control": "public, max-age=86400"
},
"routes": [
{
"route": "/images/*",
"headers": {
"Cache-Control": "public, max-age=31536000, immutable"
}
}
]
}
Terminal window
# Create Application Insights instance
az extension add --name application-insights
az monitor app-insights component create \
--app "myapp-insights" \
--location "East US" \
--resource-group $RESOURCE_GROUP \
--application-type web
# Link to Static Web App
az staticwebapp appsettings set \
--name $WEBAPP_NAME \
--resource-group $RESOURCE_GROUP \
--setting-names APPLICATIONINSIGHTS_CONNECTION_STRING \
--setting-values "$(az monitor app-insights component show --app myapp-insights -g $RESOURCE_GROUP --query connectionString -o tsv)"
// Add to your main application file
import { ApplicationInsights } from '@microsoft/applicationinsights-web';
const appInsights = new ApplicationInsights({
config: {
connectionString: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING
}
});
appInsights.loadAppInsights();
// Track image loading performance
function trackImageLoad(imageName, loadTime) {
appInsights.trackMetric({
name: 'Image Load Time',
average: loadTime,
properties: {
imageName: imageName
}
});
}
// Usage in image load handler
const startTime = performance.now();
image.onload = () => {
const loadTime = performance.now() - startTime;
trackImageLoad(imageName, loadTime);
};

Create api/optimize-image.js:

const { BlobServiceClient } = require('@azure/storage-blob');
const sharp = require('sharp');
module.exports = async function (context, req) {
const { imageName, width, height, format = 'webp', quality = 80 } = req.query;
try {
// Connect to Azure Blob Storage
const blobServiceClient = BlobServiceClient.fromConnectionString(
process.env.AZURE_STORAGE_CONNECTION_STRING
);
const containerClient = blobServiceClient.getContainerClient('images');
const blobClient = containerClient.getBlobClient(imageName);
// Download the blob
const downloadBlockBlobResponse = await blobClient.download();
const originalBuffer = await streamToBuffer(downloadBlockBlobResponse.readableStreamBody);
// Process the image
let processedImage = sharp(originalBuffer);
if (width || height) {
processedImage = processedImage.resize(parseInt(width), parseInt(height), {
fit: 'cover',
withoutEnlargement: true
});
}
// Convert format and set quality
if (format === 'webp') {
processedImage = processedImage.webp({ quality: parseInt(quality) });
} else if (format === 'jpeg') {
processedImage = processedImage.jpeg({ quality: parseInt(quality) });
}
const optimizedBuffer = await processedImage.toBuffer();
context.res = {
status: 200,
headers: {
'Content-Type': `image/${format}`,
'Cache-Control': 'public, max-age=31536000',
'Content-Length': optimizedBuffer.length
},
body: optimizedBuffer,
isRaw: true
};
} catch (error) {
context.res = {
status: 500,
body: { error: 'Failed to process image' }
};
}
};
async function streamToBuffer(readableStream) {
return new Promise((resolve, reject) => {
const chunks = [];
readableStream.on('data', (data) => {
chunks.push(data instanceof Buffer ? data : Buffer.from(data));
});
readableStream.on('end', () => {
resolve(Buffer.concat(chunks));
});
readableStream.on('error', reject);
});
}
/* CSS for progressive loading */
.image-container {
position: relative;
overflow: hidden;
}
.image-placeholder {
background: linear-gradient(90deg, #f0f0f0 25%, transparent 37%, #f0f0f0 63%);
background-size: 400% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.image-loaded {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
  1. Use WebP format for modern browsers with fallbacks
  2. Implement lazy loading for images below the fold
  3. Enable Azure CDN for global distribution
  4. Optimize image sizes for different device types
  5. Set proper caching headers
  1. Use HTTPS everywhere
  2. Implement SAS tokens for private images
  3. Configure CORS properly
  4. Regular security reviews
  1. Always include alt text
  2. Use descriptive file names
  3. Implement structured data for image galleries
  4. Optimize image loading for Core Web Vitals

Images not loading:

  • Check CORS settings on storage account
  • Verify container public access level
  • Confirm image URLs are correct

Slow loading:

  • Enable Azure CDN
  • Implement proper caching
  • Optimize image sizes

GitHub Actions failing:

  • Verify all secrets are set correctly
  • Check build output for errors
  • Ensure API token is valid

CORS errors:

Terminal window
# Fix CORS issues
az storage cors add \
--methods GET POST PUT DELETE \
--origins https://yourapp.azurestaticapps.net \
--services b \
--max-age 3600 \
--account-name $STORAGE_ACCOUNT \
--account-key $STORAGE_KEY