# Zero Build Frontend

> Zero-build frontend development with CDN-loaded React, Tailwind CSS, and vanilla JavaScript. Use when building static web apps without bundlers, creating Leaflet maps, integrating Google Sheets as database, or developing browser extensions. Covers patterns from rosen-frontend, NJCIC map, and PocketLink projects.

- **Type:** Skill
- **Install:** `agentstack add skill-jamditis-claude-skills-journalism-zero-build-frontend`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [jamditis](https://agentstack.voostack.com/s/jamditis)
- **Installs:** 0
- **Category:** [Databases](https://agentstack.voostack.com/c/databases)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [jamditis](https://github.com/jamditis)
- **Source:** https://github.com/jamditis/claude-skills-journalism/tree/master/dev-toolkit/skills/zero-build-frontend
- **Website:** https://skills.amditis.tech/

## Install

```sh
agentstack add skill-jamditis-claude-skills-journalism-zero-build-frontend
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Zero-build frontend development

Patterns for building production-quality web applications without build tools, bundlers, or complex toolchains.

## Picking a stack

Three current zero-build approaches, each with different trade-offs:

| Stack | When | Bundle size impact |
|---|---|---|
| **React via esm.sh + htm** | Component-heavy SPAs, existing React mental model, Tailwind styling | ~50 KB gzipped (React + ReactDOM + htm) |
| **htmx 2.x + server-rendered HTML** | CRUD apps, traditional MPA flow, want server-side state of truth | ~14 KB gzipped (htmx alone) |
| **Alpine.js 3.x + plain HTML** | Light interactivity sprinkled into mostly-static pages, no full SPA | ~15 KB gzipped (Alpine alone) |

You can mix htmx and Alpine.js in the same page — htmx handles server interactions, Alpine handles client-side UI state. Many production sites converge on this combo.

## ESM import maps

Import maps let you write `import x from 'react'` in a `` without a bundler — the browser resolves the bare specifier against the map. Stable in all major browsers since 2023.

```html

{
  "imports": {
    "react": "https://esm.sh/react@19.0.0",
    "react-dom/client": "https://esm.sh/react-dom@19.0.0/client",
    "lodash-es": "https://esm.sh/lodash-es@4.17.21",
    "@my-app/": "/src/"
  },
  "scopes": {
    "https://esm.sh/": {
      "scheduler": "https://esm.sh/scheduler@0.23.0"
    }
  }
}

```

The `scopes` block lets a sub-tree of imports resolve differently. Useful when one CDN package needs a specific transitive dependency. The trailing `/` form (`"@my-app/": "/src/"`) lets you import any file under that prefix.

**Pin versions in production.** `esm.sh/react` (without a version) and `esm.sh/react@latest` resolve at request time and can shift under you. Use exact pinned versions or SHA-locked URLs.

## htmx 2.x — server-rendered interactivity

htmx 2.0 (released June 2024) lets you add AJAX, WebSockets, and SSE to plain HTML through `hx-*` attributes. The server sends HTML fragments; the client swaps them in. No JS framework required.

```html

  Click me

  ...

```

htmx 2.x dropped IE support and tightened the API; if you're on htmx 1.x and don't need to migrate, 1.x still receives security patches. New code should target 2.x.

## Alpine.js 3.x — client-side reactivity in HTML

Alpine.js (current 3.14+) is a minimal alternative to Vue/React for sprinkles of interactivity. State and behavior live as `x-*` attributes in the markup.

```html

  Toggle
  Content here

  
  
  

  
    
  

```

Alpine pairs naturally with htmx: htmx swaps a server-rendered fragment in, Alpine handles whatever client-side state that fragment needs (open/close, optimistic toggles, form validation).

## React via CDN (esm.sh)

### Basic setup

```html

  
  
  Zero-Build React App

  
  
  
    tailwind.config = {
      theme: {
        extend: {
          fontFamily: {
            display: ['Special Elite', 'monospace'],
            body: ['Roboto Mono', 'monospace'],
          },
          colors: {
            brand: {
              primary: '#2dc8d2',
              secondary: '#f34213',
              dark: '#183642',
            }
          }
        }
      }
    }
  

  
  

  
  

  

  
  
  {
    "imports": {
      "react": "https://esm.sh/react@19.0.0",
      "react-dom/client": "https://esm.sh/react-dom@19.0.0/client",
      "htm": "https://esm.sh/htm@3.1.1"
    }
  }
  

  

```

### React with htm (no JSX, no build)

```javascript
// index.js
import React, { useState, useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import htm from 'htm';

// Bind htm to React.createElement
const html = htm.bind(React.createElement);

// Components use html`` instead of JSX
function App() {
  const [records, setRecords] = useState([]);
  const [loading, setLoading] = useState(true);
  const [search, setSearch] = useState('');

  useEffect(() => {
    loadData();
  }, []);

  async function loadData() {
    try {
      const response = await fetch('data/archive-data.json');
      const data = await response.json();
      setRecords(data.records);
    } catch (error) {
      console.error('Failed to load data:', error);
    } finally {
      setLoading(false);
    }
  }

  const filtered = records.filter(r =>
    r.title.toLowerCase().includes(search.toLowerCase())
  );

  if (loading) {
    return html`
      
    `;
  }

  return html`
    
      
        Archive Explorer
         setSearch(e.target.value)}
          class="mt-2 w-full p-2 bg-gray-800 rounded border border-gray-600 focus:border-brand-primary outline-none"
        />
      

      
        
      
    
  `;
}

function RecordList({ records }) {
  return html`
    
      ${records.map(record => html`
        
      `)}
    
  `;
}

function RecordCard({ record }) {
  return html`
    
      ${record.title}
      ${record.publication_date}
      ${record.summary}
      
        ${record.tags?.map(tag => html`
          ${tag}
        `)}
      
    
  `;
}

// Mount app
const root = createRoot(document.getElementById('root'));
root.render(html``);
```

## Data caching with localStorage

```javascript
// services/cacheService.js

const CACHE_TTL = 60 * 60 * 1000; // 1 hour

export function getCached(key) {
  const cached = localStorage.getItem(key);
  if (!cached) return null;

  try {
    const { data, timestamp } = JSON.parse(cached);
    if (Date.now() - timestamp > CACHE_TTL) {
      localStorage.removeItem(key);
      return null;
    }
    return data;
  } catch {
    localStorage.removeItem(key);
    return null;
  }
}

export function setCache(key, data) {
  localStorage.setItem(key, JSON.stringify({
    data,
    timestamp: Date.now()
  }));
}

export async function fetchWithCache(url, cacheKey) {
  // Check cache first
  const cached = getCached(cacheKey);
  if (cached) return cached;

  // Fetch fresh data
  const response = await fetch(url);
  const data = await response.json();

  // Cache for next time
  setCache(cacheKey, data);

  return data;
}

// Usage
const records = await fetchWithCache('data/archive-data.json', 'archive-records');
```

## Leaflet.js maps

### Basic map setup

```html

  
  
  
  
    #map { height: 85vh; width: 100%; }
  

  

  
  
  

```

### Map application with clustering

```javascript
// js/app.js

class MapApp {
  constructor() {
    this.map = null;
    this.markers = null;
    this.data = [];
    this.filters = {
      year: null,
      county: null,
      status: null
    };
  }

  async init() {
    this.setupMap();
    await this.loadData();
    this.renderMarkers();
    this.setupFilters();
  }

  setupMap() {
    // Initialize map centered on NJ
    this.map = L.map('map', {
      center: [40.0583, -74.4057],
      zoom: 8,
      scrollWheelZoom: false,  // Disable mouse wheel zoom
      zoomControl: false       // We'll add custom controls
    });

    // Add tile layer (CARTO Voyager)
    L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
      attribution: '&copy; OpenStreetMap, &copy; CARTO',
      maxZoom: 19
    }).addTo(this.map);

    // Add custom zoom control (top-right)
    L.control.zoom({ position: 'topright' }).addTo(this.map);

    // Initialize marker cluster group
    this.markers = L.markerClusterGroup({
      spiderfyOnMaxZoom: true,
      showCoverageOnHover: false,
      maxClusterRadius: 50,
      spiderLegPolylineOptions: { weight: 1.5, color: '#2dc8d2' }
    });

    this.map.addLayer(this.markers);
  }

  async loadData() {
    const response = await fetch('data/grantees.json');
    this.data = await response.json();
  }

  renderMarkers() {
    this.markers.clearLayers();

    const filtered = this.data.filter(item => {
      if (this.filters.year && item.year !== this.filters.year) return false;
      if (this.filters.county && item.county !== this.filters.county) return false;
      if (this.filters.status && item.status !== this.filters.status) return false;
      return true;
    });

    filtered.forEach(item => {
      if (!item.lat || !item.lng) return;

      const marker = L.marker([item.lat, item.lng], {
        icon: this.createIcon(item.status)
      });

      marker.bindPopup(this.createPopup(item));
      this.markers.addLayer(marker);
    });

    // Update count display
    document.getElementById('count').textContent = filtered.length;
  }

  createIcon(status) {
    const colors = {
      'Active': '#2dc8d2',
      'Completed': '#666666',
      'Pending': '#f34213'
    };

    return L.divIcon({
      html: ``,
      className: 'custom-marker',
      iconSize: [16, 16],
      iconAnchor: [8, 8]
    });
  }

  createPopup(item) {
    return `
      
        ${item.name}
        ${item.county} County
        ${item.description || ''}
        
          ${item.status}
          ${item.year}
        
        ${item.website ? `Visit Website →` : ''}
      
    `;
  }

  setupFilters() {
    // Year filter
    const years = [...new Set(this.data.map(d => d.year))].sort();
    const yearSelect = document.getElementById('year-filter');
    years.forEach(year => {
      const option = document.createElement('option');
      option.value = year;
      option.textContent = year;
      yearSelect.appendChild(option);
    });

    yearSelect.addEventListener('change', (e) => {
      this.filters.year = e.target.value || null;
      this.renderMarkers();
    });

    // Similar for county, status filters...
  }
}

// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
  const app = new MapApp();
  app.init();
});
```

## Google Sheets as database

### Fetching published CSV

```javascript
// Google Sheets published as CSV
const SHEET_URL = 'https://docs.google.com/spreadsheets/d/e/SPREADSHEET_ID/pub?gid=0&single=true&output=csv';

async function loadFromSheets() {
  const response = await fetch(SHEET_URL);
  const csv = await response.text();

  // Parse with PapaParse (CDN)
  const { data, errors } = Papa.parse(csv, {
    header: true,
    skipEmptyLines: true,
    transformHeader: (h) => h.trim().toLowerCase().replace(/\s+/g, '_')
  });

  if (errors.length > 0) {
    console.warn('CSV parsing errors:', errors);
  }

  return data;
}
```

### Real-time state with localStorage

```javascript
class DataManager {
  constructor(sheetUrl, cacheKey) {
    this.sheetUrl = sheetUrl;
    this.cacheKey = cacheKey;
    this.data = [];
    this.localState = this.loadLocalState();
  }

  loadLocalState() {
    const stored = localStorage.getItem(`${this.cacheKey}-state`);
    return stored ? JSON.parse(stored) : {};
  }

  saveLocalState() {
    localStorage.setItem(`${this.cacheKey}-state`, JSON.stringify(this.localState));
  }

  async refresh() {
    const response = await fetch(this.sheetUrl);
    const csv = await response.text();
    this.data = Papa.parse(csv, { header: true, skipEmptyLines: true }).data;

    // Merge with local state
    this.data.forEach(row => {
      const localData = this.localState[row.id];
      if (localData) {
        Object.assign(row, localData);
      }
    });

    return this.data;
  }

  updateLocal(id, updates) {
    this.localState[id] = { ...this.localState[id], ...updates };
    this.saveLocalState();

    // Update in-memory data too
    const item = this.data.find(d => d.id === id);
    if (item) Object.assign(item, updates);
  }
}

// Usage
const manager = new DataManager(SHEET_URL, 'volunteer-data');
await manager.refresh();

// Mark task as complete (stored locally)
manager.updateLocal('task-123', { completed: true, completed_at: new Date().toISOString() });
```

## Browser extension (Manifest V3)

### manifest.json

```json
{
  "manifest_version": 3,
  "name": "PocketLink",
  "version": "1.0.0",
  "description": "Create shortlinks from right-click context menu",

  "permissions": [
    "contextMenus",
    "storage",
    "activeTab",
    "scripting",
    "notifications",
    "offscreen"
  ],

  "background": {
    "service_worker": "background.js",
    "type": "module"
  },

  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },

  "options_page": "options.html",

  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}
```

### Service worker (background.js)

```javascript
// background.js - Service Worker

// Create context menu on install
chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: 'create-shortlink',
    title: 'Create Shortlink',
    contexts: ['page', 'link']
  });
});

// Handle context menu click
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId !== 'create-shortlink') return;

  const url = info.linkUrl || info.pageUrl;

  try {
    const shortUrl = await createShortlink(url);
    await copyToClipboard(shortUrl);
    showNotification('Shortlink Created', shortUrl);
  } catch (error) {
    showNotification('Error', error.message);
  }
});

async function createShortlink(longUrl) {
  const { apiToken } = await chrome.storage.sync.get('apiToken');
  if (!apiToken) throw new Error('API token not configured');

  const response = await fetch('https://api-ssl.bitly.com/v4/shorten', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiToken}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ long_url: longUrl })
  });

  if (!response.ok) throw new Error('API request failed');

  const data = await response.json();
  return data.link;
}

// Clipboard methods (three fallback strategies)

// Method 1: Offscreen API (preferred)
async function copyToClipboard(text) {
  try {
    await copyViaOffscreen(text);
  } catch {
    try {
      await copyViaContentScript(text);
    } catch {
      await copyViaPopup(text);
    }
  }
}

async function copyViaOffscreen(text) {
  await chrome.offscreen.createDocument({
    url: 'offscreen.html',
    reasons: ['CLIPBOARD'],
    justification: 'Copy shortlink to clipboard'
  });

  await chrome.runtime.sendMessage({ type: 'copy', text });
  await chrome.offscreen.closeDocument();
}

async function copyViaContentScript(text) {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: (text) => navigator.clipboard.writeText(text),
    args: [text]
  });
}

function showNotification(title, message) {
  chrome.notifications.create({
    type: 'basic',
    iconUrl: 'icons/icon48.png',
    title,
    message
  });
}
```

### Options page

```html

  
    /* Inline CSS for extension compliance (no remote code) */
    body {
      font-family: system-ui, sans-serif;
      padding: 20px;
      max-width: 400px;
      margin: 0 auto;
    }
    h1 { font-size: 1.5rem; margin-bottom: 1rem; }
    label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
    input {
      width: 100%;
      padding: 8px;
      border: 1px solid #ccc;
      border-radius: 4px;
      font-size: 14px;
    }
    button {
      margin-top: 1rem;
      padding: 10px 20px;
      background: #2dc8d2;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
    button:hover { background: #25a8b0; }
    .status { margin-top: 1rem; padding: 10px; border-radius: 4px; }
    .success { background: #d4edda; color: #155724; }
    .error { background: #f8d7da; color: #721c24; }
  

  PocketLink Settings

  Bit.ly API Token
  

  Save Settings

  

  

```

```javascript
// options.js
document.addEventListener('DOMContentLoaded', async () => {
  const tokenInput = document.getElementById('apiToken');
  const saveButton = document.getElementById('save');
  const status = document.getElementById('status');

  // Load saved token
  const { apiToken } = await chrome.storage.sync.get('apiToken');
  if (apiToken) tokenInput.value =

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [jamditis](https://github.com/jamditis)
- **Source:** [jamditis/claude-skills-journalism](https://github.com/jamditis/claude-skills-journalism)
- **License:** MIT
- **Homepage:** https://skills.amditis.tech/

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-jamditis-claude-skills-journalism-zero-build-frontend
- Seller: https://agentstack.voostack.com/s/jamditis
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
