⏺ 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):
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:
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):
Complex service (directory):
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:
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:
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):
Return standardized response: return { success: true, data: transformedData, error: null };
Key Rules:
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:
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/):
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:
Phase 5: Build Frontend (React)
Step 7: Create HTML Shell
File: frontend/index.html
Instructions:
Create basic HTML5 structure with:
Load Pico CSS (classless framework):
File: frontend/index.tsx
Instructions:
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): -
, ,Creating Additional Components:
Key Rules:
Phase 6: Shared Code
Step 10: Create Shared Types
File: shared/types.ts
Purpose: TypeScript interfaces used by both frontend and backend
Instructions:
Step 11: Create Shared Utilities
File: shared/utils.ts
Purpose: Pure functions that work in both browser and Deno
Instructions:
For Backend-Only Utils:
Phase 7: Environment Variables
Step 12: Configure Environment
Required Environment Variables:
Authentication:
External Service API Keys:
Application-Specific Config:
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?
Controllers: Does validation and orchestration work?
Routes: Do HTTP endpoints return correct responses?
Frontend: Does the UI render and fetch data?
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:
Key Principles