• Townie
    AI
  • Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
lightweight

lightweight

buckAnAcre

Public
Like
buckAnAcre
Home
Code
8
backend
1
frontend
3
.vtignore
AGENTS.md
CLAUDE.md
deno.json
H
main.http.tsx
H
main.ts
Branches
1
Pull requests
Remixes
History
Environment variables
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
CLAUDE.md
Code
/
CLAUDE.md
Search
…
CLAUDE.md

⏺ Building a Val Town MVC Application with React Frontend

This guide provides step-by-step instructions for scaffolding a Val Town application following the proven MVC architecture pattern from the proposalizer project.

Architecture Overview

project/ ├── main.http.tsx # Entry point, Hono app initialization ├── backend/ │ ├── routes/ # HTTP layer (thin wrappers) │ │ ├── api/ # RESTful API endpoints │ │ ├── tasks/ # Webhook handlers │ │ ├── views/ # User-facing pages │ │ └── authCheck.ts # Authentication middleware │ ├── controllers/ # Business logic and orchestration │ ├── services/ # External API integrations │ └── utils/ # Backend-only utilities (optional) ├── frontend/ │ ├── index.html # HTML shell with React mount point │ ├── index.tsx # React entry point │ └── components/ # React components │ └── App.tsx # Main application component └── shared/ ├── types.ts # Shared TypeScript interfaces └── utils.ts # Browser + Deno compatible utilities

Phase 1: Setup Entry Point and Core Infrastructure

Step 1: Create main.http.tsx

Purpose: Val Town HTTP entry point that initializes Hono framework, mounts routes, serves static assets

Instructions:

  1. Import Hono framework: import { Hono } from "npm:hono@4";

  2. Create Hono app instance: const app = new Hono();

  3. Add error handler (unwraps errors for better stack traces): app.onError((err, c) => { throw err; });

  4. Add global authentication middleware (before mounting routes):

    • Define array of protected route prefixes (e.g., ["/api", "/tasks"])
    • Define array of public exceptions (e.g., ["/api/health"])
    • Use middleware to check if path matches protected prefix and is not in exceptions
    • If protected, call webhookAuth middleware from authCheck.ts
  5. Add health check endpoint: app.get("/api/health", (c) => c.json({ status: "ok" }));

  6. Mount route modules (will create these later): import api from "./backend/routes/api/index.ts"; import tasks from "./backend/routes/tasks/index.ts";

app.route("/api", api); app.route("/tasks", tasks); 7. Serve frontend static files: - Import Val Town's serveFile utility - Add catch-all route app.get("", ...) - Map requests to frontend directory: - / → frontend/index.html - /index.js → frontend/index.tsx (Val Town auto-transpiles) - /components/ → frontend/components/* - Use serveFile() to return file contents 8. Export for Val Town: export default app;

Step 2: Create Authentication Middleware

File: backend/routes/authCheck.ts

Purpose: Shared secret authentication using X-API-KEY header

Instructions:

  1. Import Hono types (Context, Next)

  2. Create webhookAuth function:

    • Get WEBHOOK_SECRET from environment
    • If not set, log warning and allow request (dev mode)
    • Extract X-API-KEY header from request
    • Compare with WEBHOOK_SECRET
    • If invalid, return 401 with error message
    • If valid, call await next()
  3. Export the middleware function

Phase 2: Build Service Layer (External APIs)

Step 3: Create Service Files

Location: backend/services/

Purpose: Handle all external API calls, return standardized responses

For Each External Service:

  1. Simple service (single file):

    • Create backend/services/[serviceName]Service.ts
    • Import client library (use npm: prefix)
    • Initialize client with API key from environment
    • Export typed functions that:
      • Make API calls
      • Wrap in try/catch
      • Return { success: boolean, data: any | null, error: string | null }
  2. Complex service (directory):

    • Create backend/services/[serviceName]/ directory
    • Create index.ts:
      • Initialize shared client
      • Re-export all functions from feature files
    • Create feature files (e.g., pages.ts, databases.ts):
      • Import shared client from ./index.ts
      • Export typed functions following same pattern

Example Service Function Structure: export async function fetchSomething(id: string) { try { const result = await client.get(id); return { success: true, data: result, error: null }; } catch (err: any) { return { success: false, data: null, error: err.message }; } }

Key Rules:

  • NEVER throw errors, always return error objects
  • Store API keys in environment variables
  • One client instance per service (initialize once, reuse)
  • All functions return { success, data, error } structure

Phase 3: Build Controller Layer (Business Logic)

Step 4: Create Controllers

Location: backend/controllers/

Purpose: Orchestrate workflows, validate input, call services, transform data

Organization Strategy:

  • Group by workflow domain (e.g., pageController.ts, emailController.ts, publishingController.ts)
  • Keep files under 500 lines - split if larger
  • One controller exports multiple related functions

For Each Controller Function:

  1. Input validation: if (!requiredParam || typeof requiredParam !== 'expectedType') { return { success: false, data: null, error: "Invalid input", details: "Description of what's wrong" }; }

  2. Call service layer: const result = await someService.fetchData(id);

  3. Handle service errors: if (!result.success) { return { success: false, data: null, error: "Higher-level error message", details: result.error }; }

  4. Orchestrate multiple service calls (if needed): const data1 = await service1.fetch(); const data2 = await service2.fetch(data1.data.id);

  5. Transform/filter data (if needed):

    • Remove sensitive properties
    • Combine data from multiple sources
    • Format for frontend consumption
  6. Return standardized response: return { success: true, data: transformedData, error: null };

Key Rules:

  • NEVER make HTTP responses (controllers are HTTP-agnostic)
  • NEVER call external APIs directly (use services)
  • ALWAYS return { success, data, error, details? } structure
  • Validate ALL inputs at the top
  • Log important steps with descriptive messages

Phase 4: Build Route Layer (HTTP Interface)

Step 5: Create Route Modules

Location: backend/routes/api/, backend/routes/tasks/, backend/routes/views/

Purpose: Extract HTTP request data, call controllers, format HTTP responses

For Each Route Module:

  1. Create route file (e.g., backend/routes/api/pages.ts): import { Hono } from "npm:hono@4"; import * as controller from "../../controllers/[name]Controller.ts";

const pages = new Hono(); 2. For API endpoints (/api/): - Extract params: c.req.param('id') - Extract query: c.req.query('param') - Extract body: await c.req.json() - Call controller with extracted data - Map controller response to HTTP: if (!result.success) { return c.json({ error: result.error, details: result.details }, 400); } return c.json(result.data); 3. For webhook endpoints (/tasks/): - Wrap body parsing in try/catch - Check for two payload formats: // Notion automation format (check first) if (body?.data?.object === "page" && body?.data?.id) { pageId = body.data.id; } // Simple API format (fallback) else if (body?.pageId) { pageId = body.pageId; } - Validate required fields - Call controller - Return success/error JSON (webhooks need quick response) 4. Export route module: export default pages;

Step 6: Create Route Index Files

For each route directory (api/, tasks/, views/):

  1. Create index.ts: import { Hono } from "npm:hono@4"; import moduleA from "./moduleA.ts"; import moduleB from "./moduleB.ts";

const api = new Hono();

api.route("/moduleA", moduleA); api.route("/moduleB", moduleB);

export default api; 2. This creates final URL structure: - Mount in main: app.route("/api", api) - Route in index: api.route("/pages", pages) - Route in module: pages.get("/:id", ...) - Final URL: /api/pages/:id

Key Rules:

  • Routes are THIN wrappers (5-20 lines per endpoint)
  • NO business logic in routes
  • NEVER call services directly from routes
  • Map controller success/error to HTTP status codes
  • Return proper HTTP responses with status codes

Phase 5: Build Frontend (React)

Step 7: Create HTML Shell

File: frontend/index.html

Instructions:

  1. Create basic HTML5 structure with:

    • for your app
  2. Load Pico CSS (classless framework):

3. Add custom styles in : #root > div { padding-block: var(--pico-block-spacing-vertical); } 4. Add React mount point in body: <body> <div id="root"></div> <script type="module" src="/index.js"></script> </body> Step 8: Create React Entry Point

File: frontend/index.tsx

Instructions:

  1. Import React with pinned version and jsxImportSource: /*_ @jsxImportSource https://esm.sh/react@18.2.0 _/ import React from "https://esm.sh/react@18.2.0?deps=react@18.2.0"; import ReactDOM from "https://esm.sh/react-dom@18.2.0/client?deps=react@18.2.0";
  2. Import App component: import App from "./components/App.tsx";
  3. Create root and render: const root = ReactDOM.createRoot(document.getElementById("root")!); root.render();

Step 9: Create React Components

File: frontend/components/App.tsx

Instructions:

  1. Add jsxImportSource pragma and import React

  2. Create App component with state: export default function App() { const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState<string | null>(null);

    // Component logic

} 3. Fetch data from API (optional): React.useEffect(() => { async function fetchData() { setLoading(true); try { const response = await fetch('/api/your-endpoint'); const result = await response.json(); setData(result); } catch (err: any) { setError(err.message); } finally { setLoading(false); } } fetchData(); }, []); 4. Render with semantic HTML (Pico CSS): return (

App Title

  {loading && <p aria-busy="true">Loading...</p>}

  {error && <mark>Error: {error}</mark>}

  {data && (
    <article>
      {/* Your content */}
    </article>
  )}

  <button disabled={loading} aria-busy={loading}>
    Action
  </button>
</main>

); 5. Use semantic HTML elements (no CSS classes): -

, ,
for layout -

-

for headings - for actions - , , <select> for forms - <mark> for errors - <kbd> for tags/badges - aria-busy="true" for loading states on buttons

Creating Additional Components:

  1. Create frontend/components/ComponentName.tsx
  2. Add jsxImportSource pragma and React import
  3. Export function component
  4. Import and use in App.tsx

Key Rules:

  • ALWAYS pin React to 18.2.0 with full version string
  • ALWAYS use jsxImportSource pragma in every component file
  • Use semantic HTML only (NO CSS classes)
  • Handle loading and error states
  • Fetch from /api/* routes for dynamic data

Phase 6: Shared Code

Step 10: Create Shared Types

File: shared/types.ts

Purpose: TypeScript interfaces used by both frontend and backend

Instructions:

  1. Define data structure interfaces: export interface YourDataType { id: string; name: string; // ... other fields }
  2. Define API request/response interfaces: export interface ApiResponse { success: boolean; data: T | null; error: string | null; details?: string; }
  3. Define common types: export type Status = "draft" | "published" | "archived";

Step 11: Create Shared Utilities

File: shared/utils.ts

Purpose: Pure functions that work in both browser and Deno

Instructions:

  1. Only use standard JavaScript/TypeScript (no Deno or Node APIs)
  2. Create pure utility functions: export function formatDate(dateString: string): string { // Format logic using standard Date API }
  3. Export functions with clear names and JSDoc comments

For Backend-Only Utils:

  • Create backend/utils/ for utilities that need Deno APIs
  • Examples: crypto, logging, file system operations

Phase 7: Environment Variables

Step 12: Configure Environment

Required Environment Variables:

  1. Authentication:

    • WEBHOOK_SECRET - Shared secret for API authentication
  2. External Service API Keys:

    • [SERVICE_NAME]_API_KEY - For each external service
  3. Application-Specific Config:

    • Any app-specific configuration values

Access in code: const apiKey = Deno.env.get('SERVICE_API_KEY');

Phase 8: Testing Your Setup

Step 13: Verify Each Layer

Test Order:

  1. Services: Can you call external APIs and get data back?

    • Create simple test controller that calls service
    • Log the result
    • Verify service returns { success, data, error } structure
  2. Controllers: Does validation and orchestration work?

    • Test with valid and invalid inputs
    • Verify error messages are helpful
    • Check that multiple service calls work together
  3. Routes: Do HTTP endpoints return correct responses?

    • Test with curl or Postman
    • Verify status codes (200, 400, 401, 500)
    • Check response format
  4. Frontend: Does the UI render and fetch data?

    • Open in browser
    • Check console for errors
    • Verify data loads from API
    • Test user interactions

Common Patterns Reference

Error Handling Flow

Service returns error: { success: false, data: null, error: "API call failed" }

Controller adds context: { success: false, data: null, error: "Failed to create resource", details: "API call failed" }

Route maps to HTTP: return c.json({ error: result.error, details: result.details }, 500);

Data Flow Example

User Request → Route extracts pageId from URL → Controller validates pageId, calls service → Service fetches from external API → Controller filters sensitive data → Route returns JSON response

Webhook Handler Pattern

// Extract pageId from two possible formats let pageId: string | undefined;

if (body?.data?.object === "page" && body?.data?.id) { pageId = body.data.id; // Notion automation format } else if (body?.pageId) { pageId = body.pageId; // Simple format }

if (!pageId) { return c.json({ error: "Missing pageId" }, 400); }

// Process and return quickly const result = await controller.process(pageId); return c.json({ status: "success", data: result.data });

Architecture Checklist

Before you're done, verify:

  • ✅ main.http.tsx exports Hono app with mounted routes
  • ✅ Authentication middleware protects /api/_ and /tasks/_
  • ✅ All services return { success, data, error } structure
  • ✅ All controllers return { success, data, error, details? } structure
  • ✅ Routes are thin wrappers (no business logic)
  • ✅ Controllers never make HTTP responses
  • ✅ Services handle all external API calls
  • ✅ Frontend uses React 18.2.0 with pinned versions
  • ✅ Every React file has jsxImportSource pragma
  • ✅ Shared types are defined in shared/types.ts
  • ✅ Environment variables are used for secrets
  • ✅ Frontend uses semantic HTML with Pico CSS

Key Principles

  1. Strict layering: Route → Controller → Service (never skip)
  2. Standardized responses: Every function returns same structure
  3. No exceptions: Return error objects, don't throw
  4. Thin routes: Just HTTP concerns, no logic
  5. Smart controllers: All business logic lives here
  6. Dumb services: Just API calls, no decisions
  7. Semantic HTML: No CSS classes, use proper elements
  8. Pinned dependencies: Always specify exact versions
FeaturesVersion controlCode intelligenceCLI
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
PricingNewsletterBlogAboutCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2025 Val Town, Inc.