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

lightweight

buckAnAcre

Public
Like
buckAnAcre
Home
Code
7
backend
frontend
.vtignore
AGENTS.md
CLAUDE.md
deno.json
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
/
/
x
/
lightweight
/
buckAnAcre
/
branch
/
main
/
version
/
13
/
code
/
CLAUDE.md
/
CLAUDE.md
Code
/
/
x
/
lightweight
/
buckAnAcre
/
branch
/
main
/
version
/
13
/
code
/
CLAUDE.md
/
CLAUDE.md
Search
11/11/2025
Viewing readonly version of main branch: v13
View latest version
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 intelligenceCLIMCP
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.