⏺ 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:
-
Import Hono framework: import { Hono } from "npm:hono@4";
-
Create Hono app instance: const app = new Hono();
-
Add error handler (unwraps errors for better stack traces): app.onError((err, c) => { throw err; });
-
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
-
Add health check endpoint: app.get("/api/health", (c) => c.json({ status: "ok" }));
-
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:
-
Import Hono types (Context, Next)
-
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()
-
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:
-
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 }
-
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:
-
Input validation: if (!requiredParam || typeof requiredParam !== 'expectedType') { return { success: false, data: null, error: "Invalid input", details: "Description of what's wrong" }; }
-
Call service layer: const result = await someService.fetchData(id);
-
Handle service errors: if (!result.success) { return { success: false, data: null, error: "Higher-level error message", details: result.error }; }
-
Orchestrate multiple service calls (if needed): const data1 = await service1.fetch(); const data2 = await service2.fetch(data1.data.id);
-
Transform/filter data (if needed):
- Remove sensitive properties
- Combine data from multiple sources
- Format for frontend consumption
-
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:
- 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/):
- 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:
-
Create basic HTML5 structure with:
- for your app
-
Load Pico CSS (classless framework):
File: frontend/index.tsx
Instructions:
- 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";
- Import App component: import App from "./components/App.tsx";
- Create root and render: const root = ReactDOM.createRoot(document.getElementById("root")!); root.render();
Step 9: Create React Components
File: frontend/components/App.tsx
Instructions:
-
Add jsxImportSource pragma and import React
-
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 (
{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 headings - for actions - , , <select> for forms - <mark> for errors - <kbd> for tags/badges - aria-busy="true" for loading states on buttons
Creating Additional Components:
- Create frontend/components/ComponentName.tsx
- Add jsxImportSource pragma and React import
- Export function component
- 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:
- Define data structure interfaces: export interface YourDataType { id: string; name: string; // ... other fields }
- Define API request/response interfaces: export interface ApiResponse { success: boolean; data: T | null; error: string | null; details?: string; }
- 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:
- Only use standard JavaScript/TypeScript (no Deno or Node APIs)
- Create pure utility functions: export function formatDate(dateString: string): string { // Format logic using standard Date API }
- 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:
-
Authentication:
- WEBHOOK_SECRET - Shared secret for API authentication
-
External Service API Keys:
- [SERVICE_NAME]_API_KEY - For each external service
-
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:
-
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
-
Controllers: Does validation and orchestration work?
- Test with valid and invalid inputs
- Verify error messages are helpful
- Check that multiple service calls work together
-
Routes: Do HTTP endpoints return correct responses?
- Test with curl or Postman
- Verify status codes (200, 400, 401, 500)
- Check response format
-
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
- Strict layering: Route → Controller → Service (never skip)
- Standardized responses: Every function returns same structure
- No exceptions: Return error objects, don't throw
- Thin routes: Just HTTP concerns, no logic
- Smart controllers: All business logic lives here
- Dumb services: Just API calls, no decisions
- Semantic HTML: No CSS classes, use proper elements
- Pinned dependencies: Always specify exact versions