Skip to content

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:

export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

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:

node_modules
dist
.git
.gitignore
README.md
.env
.env.*
*.log
.DS_Store
.vscode
.idea

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

# Open in browser
open https://staging--my-static-site.app.tc1.airbase.sg

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

# If staging tests pass, deploy to production
airbase container deploy --yes

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):

No CSP violations

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:

try_files $uri $uri/ /index.html;

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:

location / {
    try_files $uri $uri/ /index.html;
}

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:

npm run build
ls -la dist/

Verify files exist in dist/: - index.html - assets/*.js - assets/*.css

Check Dockerfile copies dist/ correctly:

COPY --chown=nginx:nginx --from=builder /app/dist /usr/share/nginx/html

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:

content: [
  "./index.html",
  "./src/**/*.{js,ts,jsx,tsx}",
],

Ensure index.css imports Tailwind:

@tailwind base;
@tailwind components;
@tailwind utilities;

Build and check CSS output:

npm run build
ls -la dist/assets/*.css

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:

COPY --chown=nginx:nginx nginx.conf /etc/nginx/nginx.conf.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

npm install --save-dev rollup-plugin-visualizer

Update vite.config.ts:

import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [react(), visualizer()],
});

Build and check stats.html:

npm run build
open 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

  1. Open your Figma Make project
  2. Export as React + TypeScript
  3. Download ZIP file
  4. Extract to your project directory

Adapt for Airbase

Update package.json scripts:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  }
}

Add routing (if needed):

npm install react-router-dom

Create the deployment files: - Copy Dockerfile from this example - Copy nginx.conf from this example - Create airbase.json - Create .dockerignore

Deploy:

airbase container build
airbase container deploy --yes staging


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