Static Site Example (Figma Make)¶
Complete example: Deploy a static single-page application to Airbase
This example shows how to build and deploy a React-based static site (like those exported from Figma Make) to Airbase using a multi-stage Docker build with Nginx.
Overview¶
What we're building: - Static single-page application (SPA) - Client-side routing - Optimized production build - Nginx web server
Tech stack: - React + TypeScript - Vite (build tool) - Tailwind CSS - Nginx (web server)
Deployment time: ~5 minutes
Project Structure¶
my-static-site/
├── src/
│ ├── App.tsx # Main React component
│ ├── main.tsx # Entry point
│ └── index.css # Styles
├── public/ # Static assets
├── index.html # HTML template
├── package.json # Node dependencies
├── tsconfig.json # TypeScript config
├── vite.config.ts # Vite configuration
├── tailwind.config.js # Tailwind CSS config
├── postcss.config.js # PostCSS config
├── nginx.conf # Nginx configuration
├── Dockerfile # Multi-stage build
├── .dockerignore # Build exclusions
└── airbase.json # Airbase configuration
Step 1: Create React Application¶
package.json:
{
"name": "my-static-site",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.2",
"vite": "^5.1.5"
}
}
Step 2: Create Application Code¶
src/App.tsx:
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
function Home() {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="container mx-auto px-4 py-16">
<div className="max-w-2xl mx-auto text-center">
<h1 className="text-5xl font-bold text-gray-900 mb-6">
Welcome to Airbase
</h1>
<p className="text-xl text-gray-600 mb-8">
Static site deployment made simple
</p>
<div className="flex gap-4 justify-center">
<Link
to="/about"
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition"
>
Learn More
</Link>
<a
href="https://docs.airbase.sg"
className="px-6 py-3 bg-white text-indigo-600 border-2 border-indigo-600 rounded-lg hover:bg-indigo-50 transition"
>
Documentation
</a>
</div>
</div>
</div>
</div>
);
}
function About() {
return (
<div className="min-h-screen bg-white">
<div className="container mx-auto px-4 py-16">
<div className="max-w-3xl mx-auto">
<Link to="/" className="text-indigo-600 hover:text-indigo-800 mb-8 inline-block">
← Back to Home
</Link>
<h1 className="text-4xl font-bold text-gray-900 mb-6">
About This Site
</h1>
<div className="prose prose-lg">
<p className="text-gray-600 mb-4">
This is a static single-page application deployed on Airbase.
</p>
<h2 className="text-2xl font-semibold text-gray-900 mt-8 mb-4">
Features
</h2>
<ul className="space-y-2 text-gray-600">
<li>✅ React with TypeScript</li>
<li>✅ Client-side routing with React Router</li>
<li>✅ Tailwind CSS for styling</li>
<li>✅ Vite for fast builds</li>
<li>✅ Nginx for serving static files</li>
<li>✅ Multi-stage Docker build</li>
<li>✅ CSP-compliant (no inline scripts)</li>
</ul>
<h2 className="text-2xl font-semibold text-gray-900 mt-8 mb-4">
Technology Stack
</h2>
<ul className="space-y-2 text-gray-600">
<li><strong>Build:</strong> Vite 5.x</li>
<li><strong>Framework:</strong> React 18</li>
<li><strong>Language:</strong> TypeScript</li>
<li><strong>Styling:</strong> Tailwind CSS</li>
<li><strong>Server:</strong> Nginx 1.28</li>
</ul>
</div>
</div>
</div>
</div>
);
}
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Router>
);
}
export default App;
src/main.tsx:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
src/index.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Static Site | Airbase</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Step 3: Create Configuration Files¶
vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
},
},
},
},
});
tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
tsconfig.node.json:
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
tailwind.config.js:
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
postcss.config.js:
Step 4: Create Nginx Configuration¶
nginx.conf:
# Nginx configuration for SPA with client-side routing
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
server {
listen ${PORT};
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Content Security Policy (enforced by Airbase)
# CSP header is automatically added by Airbase platform
# script-src 'self' - Only scripts from same origin
# SPA routing: try file, then directory, then index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
}
}
Why this configuration: - ✅ Listens on $PORT environment variable (Airbase requirement) - ✅ try_files enables client-side routing (SPA support) - ✅ Security headers included - ✅ Static asset caching for performance - ✅ Health check endpoint - ✅ Gzip compression
Step 5: Create Multi-Stage Dockerfile¶
Dockerfile:
# Stage 1: Build the application
FROM gdssingapore/airbase:node-22-builder AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Stage 2: Serve with Nginx
FROM gdssingapore/airbase:nginx-1.28
# Copy built files from builder stage
COPY --chown=nginx:nginx --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY --chown=nginx:nginx nginx.conf /etc/nginx/nginx.conf.template
# Create startup script to substitute PORT env var
RUN echo '#!/bin/sh' > /docker-entrypoint.sh && \
echo 'envsubst "\$PORT" < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf' >> /docker-entrypoint.sh && \
echo 'exec nginx -g "daemon off;"' >> /docker-entrypoint.sh && \
chmod +x /docker-entrypoint.sh
# Use nginx user (non-root)
USER nginx
# Expose port
EXPOSE 3000
# Start nginx with environment variable substitution
CMD ["/docker-entrypoint.sh"]
Key features: - ✅ Multi-stage build (smaller final image) - ✅ Node.js builder for compiling assets - ✅ Nginx for serving static files - ✅ Non-root user (nginx) - ✅ Environment variable substitution for PORT - ✅ Proper file ownership
Step 6: Create .dockerignore¶
.dockerignore:
Why: Excludes unnecessary files from Docker build context, making builds faster.
Step 7: Create Airbase Configuration¶
airbase.json:
{
"framework": "container",
"handle": "your-team/my-static-site",
"port": 3000,
"instanceType": "nano"
}
Configuration notes: - port: 3000 (Nginx listens on PORT env var) - instanceType: nano is sufficient for static sites (0.25 vCPU, 512MB RAM) - handle: Replace with your actual project handle
Deploy to Airbase¶
Build and Deploy to Staging¶
# Navigate to project directory
cd my-static-site
# Build container
airbase container build
# Deploy to staging for testing
airbase container deploy --yes staging
Expected output:
Building container...
✓ Build complete
✓ Image pushed to registry
Deploying to staging environment...
✓ Deployment successful
✓ Application is live at: https://staging--my-static-site.app.tc1.airbase.sg
Deployment time: 3-5 minutes
Test in Staging¶
What to test: - [ ] Homepage loads correctly - [ ] Client-side routing works (click "Learn More") - [ ] Browser back/forward buttons work - [ ] Direct URL access works (e.g., /about) - [ ] No CSP errors in browser console (F12) - [ ] Health check responds: curl https://staging--my-static-site.app.tc1.airbase.sg/health
Deploy to Production¶
Production URL: https://my-static-site.app.tc1.airbase.sg
CSP Compliance¶
React + Vite CSP Compatibility¶
Good news: Vite produces CSP-compliant builds by default.
How Vite handles CSP: 1. All JavaScript bundled into external .js files 2. No inline scripts in HTML 3. No eval() or dynamic code generation 4. All code loaded via <script src="...">
What's CSP-compliant: - ✅ React components (compiled to external JS) - ✅ React Router (client-side routing) - ✅ Tailwind CSS (compiled to external CSS) - ✅ External script tags - ✅ Event handlers via addEventListener
What to avoid: - ❌ Inline <script> tags - ❌ Inline event handlers (onclick="...") - ❌ eval() or new Function() - ❌ Third-party widgets with inline scripts
Verify CSP Compliance¶
Step 1: Open browser DevTools (F12)
Step 2: Check Console tab
Good (no errors):
Bad (has violations):
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'".
Step 3: Check Network tab
All .js files should load from same domain (e.g., my-static-site.app.tc1.airbase.sg/assets/index-abc123.js).
Client-Side Routing¶
How It Works¶
Traditional multi-page app: - / → server returns index.html - /about → server returns about.html
Single-page app: - / → server returns index.html, React renders Home - /about → server returns index.html, React renders About - No page reload, faster navigation
Nginx Configuration for SPA¶
Critical line in nginx.conf:
What this does: 1. Try to serve the file at the requested path 2. Try to serve it as a directory 3. If neither exists, serve index.html (let React Router handle it)
Why it matters: - User visits /about directly (e.g., via bookmark) - Nginx serves index.html - React Router sees /about path and renders About component - Works correctly
Without this line: - User visits /about - Nginx returns 404 (no file at /about) - App breaks
Troubleshooting¶
Issue: 404 on direct URL access¶
Symptom: Clicking links works, but visiting /about directly shows 404.
Cause: Nginx not configured for SPA routing.
Solution: Ensure nginx.conf has:
Issue: Blank page after deployment¶
Symptom: Deployment succeeds but page is blank.
Causes: 1. Build failed silently 2. Wrong base path in Vite config 3. Files not copied correctly
Solution:
Check build output:
Verify files exist in dist/: - index.html - assets/*.js - assets/*.css
Check Dockerfile copies dist/ correctly:
Issue: CSS not loading¶
Symptom: Page loads but has no styling.
Cause: Tailwind not configured correctly.
Solution:
Ensure tailwind.config.js content paths are correct:
Ensure index.css imports Tailwind:
Build and check CSS output:
Issue: Environment variable PORT not working¶
Symptom: App not accessible, or wrong port.
Cause: Nginx not substituting $PORT variable.
Solution:
Use envsubst in startup script:
RUN echo '#!/bin/sh' > /docker-entrypoint.sh && \
echo 'envsubst "\$PORT" < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf' >> /docker-entrypoint.sh && \
echo 'exec nginx -g "daemon off;"' >> /docker-entrypoint.sh && \
chmod +x /docker-entrypoint.sh
Name template file .template:
Issue: Large bundle size / slow loading¶
Symptom: App takes long to load initially.
Solution 1: Code splitting
Update vite.config.ts:
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
},
},
},
Solution 2: Lazy loading
Use dynamic imports:
import { lazy, Suspense } from 'react';
const About = lazy(() => import('./About'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Suspense>
);
}
Solution 3: Analyze bundle
Update vite.config.ts:
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [react(), visualizer()],
});
Build and check stats.html:
Production Optimization¶
Enable Compression¶
Already enabled in nginx.conf:
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
Benefit: ~70% smaller file sizes
Cache Static Assets¶
Already configured in nginx.conf:
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
Benefit: Repeat visitors load instantly
CDN for Assets (Optional)¶
For high-traffic applications, consider using a CDN: 1. Upload assets to S3 2. Configure CloudFront 3. Update asset URLs
Benefit: Faster loading globally
Performance Monitoring¶
Add basic performance tracking:
src/main.tsx:
// Log performance metrics
window.addEventListener('load', () => {
const perfData = window.performance.timing;
const pageLoadTime = perfData.loadEventEnd - perfData.navigationStart;
console.log(`Page load time: ${pageLoadTime}ms`);
});
Advanced: Multiple Pages¶
Add more routes:
src/App.tsx:
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import Home from './pages/Home';
import About from './pages/About';
import Contact from './pages/Contact';
import NotFound from './pages/NotFound';
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Router>
);
}
No Nginx changes needed - try_files handles all routes automatically.
Production Checklist¶
Before deploying to production:
- Test all routes in staging
- Verify client-side routing works (direct URL access)
- Check browser console for CSP errors
- Test on multiple browsers (Chrome, Firefox, Safari)
- Test on mobile devices
- Verify health check endpoint responds
- Check bundle size (should be < 1MB for basic apps)
- Test with slow network (Chrome DevTools throttling)
- Ensure all images and assets load correctly
- Test 404 page (if implemented)
- Review Nginx logs for errors
Figma Make Integration¶
If you're deploying a site exported from Figma Make:
Export from Figma Make¶
- Open your Figma Make project
- Export as React + TypeScript
- Download ZIP file
- Extract to your project directory
Adapt for Airbase¶
Update package.json scripts:
Add routing (if needed):
Create the deployment files: - Copy Dockerfile from this example - Copy nginx.conf from this example - Create airbase.json - Create .dockerignore
Deploy:
Next Steps¶
Enhance your static site: - Add form handling with API backend - Integrate with backend services - Add authentication (OAuth) - Implement analytics - Add error boundary components
Resources: - React Documentation - Vite Documentation - React Router Documentation - Tailwind CSS Documentation - Nginx Documentation
Complete Example Repository¶
You can find the complete working example at:
my-static-site/
├── src/
│ ├── App.tsx # 80 lines
│ ├── main.tsx # 10 lines
│ └── index.css # 10 lines
├── index.html # 15 lines
├── package.json # 30 lines
├── vite.config.ts # 20 lines
├── tsconfig.json # 25 lines
├── tailwind.config.js # 10 lines
├── postcss.config.js # 7 lines
├── nginx.conf # 50 lines
├── Dockerfile # 25 lines
├── .dockerignore # 10 lines
└── airbase.json # 6 lines
Total: ~300 lines of code for a complete production-ready static site.
See Also¶
- Tutorial: Getting Started - First deployment
- How-To: Build and Deploy - Deployment workflow
- Reference: Dockerfile Requirements - Container requirements
- Reference: Base Images - Node and Nginx base images
- How-To: CSP Compliance - CSP best practices
- Example: Python Streamlit - Python web app example