This file provides project-specific development guidelines for working with this Val Town application.
Prerequisites: This assumes you've completed initial setup. If scaffolding a new val from scratch, see SCAFFOLD.md first.
Note: For general Val Town platform patterns (triggers, redirects, standard library, React config), see AGENTS.md.
This is a Notion webhook integration application that:
- Receives webhooks from Notion when pages/databases change
- Processes Notion data and integrates with external services (Firecrawler, etc.)
- Saves processed results back to Notion
- Provides a React frontend for viewing and managing Notion data
This val follows a strict 3-layer MVC architecture. See sections below for detailed patterns.
When creating ANY /tasks/* endpoint:
This application receives webhooks from Notion automations. The payload structure is NOT what you might expect:
- ❌ WRONG: Looking for
body.pageId - ✅ CORRECT: Page ID is at
body.data.id
Always check body.data.id first when handling Notion webhooks. See the detailed "Notion Integration Patterns" section for the complete webhook handler pattern and payload format.
Quick check:
// Notion automation format (ALWAYS check this first)
if (body?.data?.object === "page" && body?.data?.id) {
pageId = body.data.id;
}
This is documented in detail in the "Notion Integration Patterns" section below.
Note: This project uses npm: imports for backend packages (diverging from the general Val Town pattern in AGENTS.md which recommends esm.sh everywhere). We use npm: for better performance with packages like Notion SDK that only run server-side.
This val runs in different environments, so import patterns matter.
For code that ONLY runs in the backend (controllers, services, routes, utils, main.http.tsx):
Use npm: specifier:
import { Hono } from "npm:hono@4";
import { Client } from "npm:@notionhq/client@2";
Benefits:
- ✅ Modern Deno way
- ✅ Faster resolution (no CDN roundtrip)
- ✅ Better caching
- ✅ More explicit about npm packages
Val Town utilities still use URLs:
import {
readFile,
serveFile,
} from "https://esm.town/v/std/utils@85-main/index.ts";
For code in frontend/ and shared/ that runs in the BROWSER:
Use esm.sh with version pins:
// Frontend React components
import { useState } from "https://esm.sh/react@18.2.0?deps=react@18.2.0";
// Shared utilities (work in both browser and Deno)
import { getPropertyValue } from "../../shared/utils.ts";
Why esm.sh for frontend:
- ✅ Works in browser (no
npm:support) - ✅ CDN delivery for client-side
- ✅ Automatic bundling and transpilation
| Location | Environment | Import Pattern | Example |
|---|---|---|---|
backend/ | Deno only | npm:package@version | npm:hono@4 |
main.http.tsx | Deno only | npm:package@version | npm:hono@4 |
frontend/ | Browser | https://esm.sh/package@version | https://esm.sh/react@18.2.0 |
shared/ | Both | https://esm.sh/package@version | https://esm.sh/date-fns@3 |
| Val Town utils | Deno | https://esm.town/v/... | Val Town standard library |
Backend (use npm:):
- Hono:
npm:hono@4 - Notion:
npm:@notionhq/client@2 - Database drivers, server-only utilities
Frontend (use esm.sh):
- React:
https://esm.sh/react@18.2.0 - React DOM:
https://esm.sh/react-dom@18.2.0/client - Browser-compatible libraries
Shared (use esm.sh):
- Date utilities:
https://esm.sh/date-fns@3 - Validation libraries:
https://esm.sh/zod@3 - Any package used in both frontend and backend
This val follows a 3-layer architecture. Every feature MUST follow this pattern. NEVER skip layers.
Request → Route → Controller → Service → External API
↓
Response ← Format ← Standard Response ← Result
Location:
routes/api/- API endpoints for data operationsroutes/tasks/- Notion webhook handlersroutes/views/- User-facing HTML/React views
Responsibility: HTTP ONLY
- Extract request parameters (query, body, headers)
- Apply authentication middleware from
authCheck.ts - Call controller functions
- Format controller responses into HTTP responses
- Map success/error to HTTP status codes
Rules:
- ❌ NO business logic in routes
- ❌ NEVER call services directly from routes
- ✅ Routes are thin wrappers around controllers
- ✅ Always return proper HTTP responses with status codes:
200- Success400- Validation errors, bad request401- Authentication failures500- Server/external service errors
Example pattern:
app.post("/api/tasks/:id", async (c) => {
const pageId = c.req.param("id");
const body = await c.req.json();
const result = await pageController.updatePage(pageId, body);
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
return c.json(result.data);
});
Responsibility: Business logic and orchestration
- Validate request data
- Orchestrate multiple service calls
- Transform and filter data
- Remove sensitive information
IMPORTANT: Work with Notion pages generically
- The fundamental object in Notion is a page (not task, project, etc.)
- Controllers and services should use
pageIdand work with any Notion page - Routes can be domain-specific (e.g.,
/api/tasks/:id), but they should call generic page controllers - This allows the architecture to handle any type of Notion page (tasks, projects, notes, etc.)
MUST return this exact structure:
{
success: boolean,
data: any | null,
error: string | null,
details?: string // Additional error context
}
Rules:
- ❌ NEVER return HTTP responses (controllers are HTTP-agnostic)
- ❌ NEVER call external APIs directly
- ✅ Always call service layer functions
- ✅ Return standardized response structure
- ✅ Validate all input data
- ✅ Filter sensitive data (e.g., button properties)
Example pattern:
export async function updatePage(pageId: string, properties: any) {
// Validate
if (!pageId || !properties) {
return {
success: false,
data: null,
error: "Invalid input",
details: "pageId and properties are required",
};
}
// Call service
const result = await notionService.updatePage(pageId, properties);
if (!result.success) {
return {
success: false,
data: null,
error: "Failed to update page",
details: result.error,
};
}
return {
success: true,
data: result.data,
error: null,
};
}
Responsibility: External API calls ONLY
- Make HTTP requests to external APIs
- Handle API authentication
- Parse and normalize API responses
- Manage retries and API-specific error handling
Create services for:
- Notion API (
notionService.ts) - Firecrawler (
firecrawlerService.ts) - Val Town blob storage (
blobService.ts) - Any other external service
Rules:
- ✅ Return structured results with
{success, data, error} - ✅ Handle all API errors gracefully (don't throw)
- ✅ Use environment variables for API keys
- ✅ Export typed functions
Example pattern:
export async function getNotionPage(pageId: string) {
try {
const notion = new Client({ auth: Deno.env.get("NOTION_API_KEY") });
const page = await notion.pages.retrieve({ page_id: pageId });
return {
success: true,
data: page,
error: null,
};
} catch (err) {
return {
success: false,
data: null,
error: err.message,
};
}
}
When asked to "build a /tasks/ endpoint" or any new feature, follow this checklist:
-
✅ Create route file in appropriate directory:
- Webhook handlers →
routes/tasks/ - API endpoints →
routes/api/ - Views →
routes/views/
- Webhook handlers →
-
✅ Extract request data in route (params, query, body)
- ⚠️ CRITICAL for
/tasks/*routes: Follow the Notion webhook payload format pattern in the "Notion Integration Patterns" section - Always check
body.data.idfirst for Notion automation webhooks - Fall back to
body.pageIdfor simple API calls
- ⚠️ CRITICAL for
-
✅ Create controller function in
backend/controllers/[feature].ts- Implement validation
- Implement business logic
- Orchestrate service calls
-
✅ Create/update service functions in
backend/services/[service].ts- Make external API calls
- Return
{success, data, error}structure
-
✅ Return standardized response from controller
-
✅ Format HTTP response in route based on controller result
-
✅ Add TypeScript types to
shared/types.tsif data is shared across layers -
✅ Import route into
main.http.tsxand mount on Hono app -
✅ Test the endpoint
Note: Authentication is applied globally in main.http.tsx for all /api/* and /tasks/* routes (except /api/health). No per-route authentication setup needed.
All Notion webhooks live in routes/tasks/. When creating webhook handlers:
- Validate webhook signature/authentication
- Extract Notion event data (see payload formats below)
- Call appropriate controller
- Return
200status quickly (webhooks timeout) - Val Town doesn't support background tasks - process synchronously
IMPORTANT: When creating /tasks/* endpoints that receive Notion webhooks, you MUST handle the Notion automation payload format correctly.
When webhooks are triggered from Notion automations or buttons, the payload structure is:
{
"source": {
"type": "automation",
"automation_id": "...",
"action_id": "...",
"event_id": "...",
"user_id": "...",
"attempt": 1
},
"data": {
"object": "page", // or "database"
"id": "page-id-here", // ⚠️ THE PAGE ID IS HERE
"created_time": "...",
"last_edited_time": "...",
"properties": { ... },
"url": "...",
...
}
}
Key Points:
- ⚠️ Page ID location:
body.data.id(NOTbody.pageId) - ⚠️ Object type: Check
body.data.object === "page"to validate - The entire page object is in
body.data - Source metadata is in
body.source
For direct API calls (not from Notion), you may also support:
{
"pageId": "page-id-here",
"category": "proposal", // optional
...additional params
}
ALWAYS use this pattern for Notion webhook handlers:
import { Hono } from "npm:hono@4";
import * as controller from "../../controllers/someController.ts";
const route = new Hono();
route.post("/", async (c) => {
let body: any;
try {
body = await c.req.json();
} catch (err: any) {
console.error("Failed to parse request body:", err.message);
return c.json({ error: "Invalid JSON in request body" }, 400);
}
// Extract pageId from either format
let pageId: string | undefined;
let category = "default-category";
// 1. Check for Notion automation format (body.data.id)
if (body?.data?.object === "page" && body?.data?.id) {
pageId = body.data.id;
console.log("Detected Notion automation webhook format");
}
// 2. Check for simple API format (body.pageId)
else if (body?.pageId) {
pageId = body.pageId;
category = body.category || "default-category";
console.log("Detected simple API format");
}
// Validate pageId
if (!pageId) {
console.error(
"Webhook called with missing pageId. Body:",
JSON.stringify(body)
);
return c.json(
{
error: "Missing pageId",
details:
"Expected Notion automation format (data.id) or simple format (pageId)",
},
400
);
}
// Process the request
try {
const result = await controller.someFunction(pageId, category);
if (!result.success) {
console.error(`Operation failed for ${pageId}:`, result.error);
return c.json(
{
status: "error",
error: result.error,
details: result.details,
},
500
);
}
console.log(`Operation successful:`, result.data);
return c.json({
status: "success",
message: "Operation completed",
data: result.data,
});
} catch (err: any) {
console.error("Error processing webhook:", err.message);
return c.json(
{
status: "error",
error: "Internal server error",
details: err.message,
},
500
);
}
});
export default route;
Rules for /tasks/* webhook handlers:
- ✅ ALWAYS check for
body.data.idfirst (Notion automation format) - ✅ Fall back to
body.pageIdfor simple API calls - ✅ Log which format was detected
- ✅ Include full body in error logs when pageId is missing
- ✅ Process synchronously (Val Town doesn't support background tasks)
- ✅ Return detailed error messages
- ❌ NEVER assume
body.pageIdexists - checkbody.data.idfirst - ❌ NEVER use
c.executionCtx.waitUntil()(Cloudflare Workers only)
Service Integration:
- Follow the "External Service Integration Pattern" section below for organizing Notion service functions
When integrating a simple external service (like Firecrawler):
-
Create service file:
backend/services/[serviceName].ts -
Initialize client and export typed functions:
import { SomeClient } from "npm:some-client@1";
export const client = new SomeClient({
apiKey: Deno.env.get("SERVICE_API_KEY"),
});
export async function fetchFromService(params: ServiceParams) {
try {
const result = await client.fetch(params);
return { success: true, data: result, error: null };
} catch (err: any) {
return { success: false, data: null, error: err.message };
}
}
When integrating a complex service with multiple concerns (like Notion with pages, databases, blocks):
-
Create service directory:
backend/services/[serviceName]/ -
Create index.ts - Initialize client and re-export:
import { Client } from "npm:@servicename/client@2";
export const client = new Client({ auth: Deno.env.get("SERVICE_API_KEY") });
export * from "./pages.ts";
export * from "./databases.ts";
- Create feature files - Import shared client:
// backend/services/notion/pages.ts
import { client } from "./index.ts";
export async function getPage(id: string) {
try {
const result = await client.pages.get(id);
return { success: true, data: result, error: null };
} catch (err: any) {
return { success: false, data: null, error: err.message };
}
}
- Store API keys in environment:
Deno.env.get('SERVICE_API_KEY') - Handle errors gracefully: Return error objects, don't throw
- Add types to
shared/types.tsif data is used across layers - Call service from controller, never from routes
- One client instance: Initialize once, reuse everywhere
- Files: camelCase (e.g.,
notionService.ts,pageController.ts) - Functions: camelCase, verb-first (e.g.,
getNotionPage,updatePage) - Types/Interfaces: PascalCase (e.g.,
NotionWebhookPayload,PageResponse) - Routes: kebab-case URLs (e.g.,
/api/notion-tasks,/tasks/webhook-handler)
-
Controllers: Organize by purpose or context
healthController.ts- System health checks (PUBLIC, default on frontend)pageController.ts- Generic Notion page operations (get, update, create)webhookController.ts- Webhook processing logic- Note: Controllers should work with
pageIdgenerically, not task/project-specific - Group related operations together (e.g., all page operations in one controller)
-
Services: Organize by external API
- For complex services, use directories:
notion/- Notion API integrationindex.ts- Client initialization and re-exportspages.ts- Page operationsdatabases.ts- Database operations
- For simple services, use single files:
firecrawlerService.ts- Firecrawler integrationblobService.ts- Val Town blob storage
- For complex services, use directories:
-
Utils: Backend-only utility functions (
backend/utils/)- IMPORTANT: Pure utility functions with NO external dependencies and NO business logic
- Can use Deno APIs (unlike
shared/utils.tswhich must work in browser) - Examples of what belongs here:
cryptoUtils.ts- Hashing, encryption, JWT signing (using Deno's Web Crypto)dateUtils.ts- Date formatting, timezone conversions (backend-specific)validationUtils.ts- Input validation helpers (using Deno features)logUtils.ts- Logging formattersenvUtils.ts- Environment variable parsers
- Examples of what does NOT belong here:
- ❌ External API calls (use
backend/services/) - ❌ Business logic (use
backend/controllers/) - ❌ Browser-compatible code (use
shared/utils.ts) - ❌ Database operations (use
backend/services/)
- ❌ External API calls (use
- Export pure functions with clear, single responsibilities
- No side effects (no global state, no I/O unless explicitly for that purpose)
-
Routes: Organize by purpose
routes/api/- RESTful API endpoints (/api/tasks,/api/tasks/:id)routes/tasks/- Webhook handlers (/tasks/notion-webhook)routes/views/- User-facing pages (/views/dashboard)
-
Shared: Cross-layer code
shared/types.ts- TypeScript interfacesshared/utils.ts- Pure utility functions (work in browser AND Deno)
Services: Return errors, don't throw
return { success: false, data: null, error: "API call failed" };
Controllers: Add context to service errors
if (!result.success) {
return {
success: false,
data: null,
error: "Failed to update page",
details: result.error,
};
}
Routes: Map to HTTP status codes
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
Add to main.http.tsx: Hono error unwrapper for better stack traces
app.onError((err, c) => {
throw err;
});
- Authentication middleware lives in
routes/authCheck.ts - Uses shared secret approach with
X-API-KEYheader - Applied globally in
main.http.tsxusing array-based protection - Protects
/api/*and/tasks/*routes with exceptions for public endpoints
Array-based protection pattern in main.http.tsx:
import { webhookAuth } from "./backend/routes/authCheck.ts";
// Define protected route prefixes and public exceptions
const protectedPrefixes = ["/api", "/tasks"];
const publicExceptions = ["/api/health"];
// Apply middleware before mounting routes
app.use("*", async (c, next) => {
const path = c.req.path;
// Check if path is a public exception (exact match)
if (publicExceptions.includes(path)) {
await next();
return;
}
// Check if path starts with any protected prefix
const isProtected = protectedPrefixes.some((prefix) =>
path.startsWith(prefix)
);
if (isProtected) {
await webhookAuth(c, next);
} else {
await next();
}
});
Usage in webhook configuration:
- Header name:
X-API-KEY - Header value: Value of
WEBHOOK_SECRETenvironment variable
Development mode:
- If
WEBHOOK_SECRETnot set, authentication is bypassed with console warning
frontend/index.html- HTML shellfrontend/index.tsx- React entry pointfrontend/components/App.tsx- Main componentfrontend/components/NotionBlock.tsx- Notion block rendererfrontend/components/NotionProperty.tsx- Property display
Configuration: See AGENTS.md for React 18.2.0 setup with pinned dependencies and jsxImportSource
Data Fetching:
- Fetch from
/api/*routes - Use shared types from
shared/types.ts - Handle loading and error states
This val uses Pico CSS - a minimal CSS framework that automatically styles semantic HTML.
- No CSS classes needed - Keeps HTML clean and maintainable (we use it in classless mode)
- Semantic HTML only - Use proper HTML elements (
<article>,<main>,<kbd>, etc.) - Automatic styling - Beautiful design out of the box
- CDN delivery - Single
<link>tag, no build step - Customizable - Easy to add custom styles when needed
❌ NEVER use CSS class names (no className attributes)
❌ NEVER use TailwindCSS, Bootstrap, or other class-based frameworks
✅ ALWAYS use semantic HTML elements
✅ Use inline styles sparingly - Only for dynamic/conditional styling
Use these semantic elements - Pico styles them automatically:
Layout:
<main>- Main content container<article>- Self-contained content (cards, sections)<section>- Thematic grouping of content<header>,<footer>- Page/section headers and footers<aside>- Sidebar content
Typography:
<h1>through<h6>- Headings<p>- Paragraphs<strong>- Bold/important text<em>- Italic/emphasized text<small>- Fine print<mark>- Highlighted text<code>- Inline code<pre><code>- Code blocks<kbd>- Keyboard input / tags
Lists:
<ul>,<ol>,<li>- Lists<dl>,<dt>,<dd>- Definition lists
Forms:
<input>,<textarea>,<select>- Form inputs<button>- Buttons<label>- Form labels- Use
aria-busyattribute for loading states on buttons
Links & Media:
<a>- Links<time>- Dates and times<figure>,<figcaption>- Images with captions
Card layout:
<article> <h2>Card Title</h2> <p>Card content goes here.</p> <button>Action</button> </article>
Form with loading state:
<div style={{ display: "flex", gap: "1rem" }}>
<input type="text" placeholder="Enter value" style={{ flex: 1 }} />
<button disabled={loading} aria-busy={loading}>
Submit
</button>
</div>
Error message:
{ error && <mark>{error}</mark>; }
Tags/badges:
Conditional styling:
<span
style={checked ? { textDecoration: "line-through", opacity: 0.6 } : undefined}
>
Task text
</span>
Already included in frontend/index.html:
<!-- Pico CSS - CSS framework --> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" /> <style> #root > div { padding-block: var(--pico-block-spacing-vertical); } </style>
Note: We use Pico's main stylesheet but apply it in a classless way (semantic HTML only). The custom style adds proper padding to the root React container.
Types (shared/types.ts):
- All TypeScript interfaces shared between frontend/backend
- Notion data structures
- API request/response types
- Common data models
Utils (shared/utils.ts):
- Pure functions that work in both Deno (backend) and browser (frontend)
- ❌ NEVER use Deno APIs (won't work in browser)
- ✅ Only use standard JavaScript/TypeScript
- Note: For backend-only utilities that need Deno APIs, use
backend/utils/instead
Required environment variables for this val:
NOTION_API_KEY- Notion integration tokenWEBHOOK_SECRET- Shared secret for API authentication (optional in dev mode)BLOB_KEY_PREFIX_PROJECT- Static project identifier for blob keys (e.g., "acme", "internal") - REQUIREDBLOB_KEY_PREFIX_TYPE- Static type identifier for blob keys (e.g., "proposals", "tasks") - REQUIRED- Additional service API keys as needed
Blob Key Format: Blob keys are automatically generated using the format: ${BLOB_KEY_PREFIX_PROJECT}--${BLOB_KEY_PREFIX_TYPE}--${pageId}
Access via: Deno.env.get('VAR_NAME') (see AGENTS.md for details)
Validate request data in controllers, not routes:
if (!pageId || typeof pageId !== "string") {
return {
success: false,
data: null,
error: "Invalid pageId",
details: "pageId must be a non-empty string",
};
}
Check:
- Required fields exist
- Types are correct
- Formats are valid
- Values are within expected ranges
Return helpful error messages with details field
Filter sensitive data in controllers before returning:
- Remove button properties from Notion data
- Don't expose internal IDs or tokens
- Clean up API responses before sending to frontend
- Strip unnecessary metadata
Example:
// Remove sensitive properties
const filtered = {
...notionData,
properties: Object.entries(notionData.properties)
.filter(([key, value]) => value.type !== "button")
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
};
When the user requests a new feature, clarify:
-
Is this a webhook handler or API endpoint?
- Webhook →
routes/tasks/ - API →
routes/api/
- Webhook →
-
What external service needs integration?
- New service → create
backend/services/[name].ts
- New service → create
-
What data needs to be saved back to Notion?
- Update Notion → use/extend
notionService.ts
- Update Notion → use/extend
-
Should this endpoint be public (no authentication)?
- Yes → Add to
publicExceptionsarray inmain.http.tsx - No → Authentication already applied to all
/api/*and/tasks/*routes
- Yes → Add to
-
Is this frontend-facing?
- Yes → create React component in
frontend/components/
- Yes → create React component in
Before implementing, verify:
- ✅ Following 3-layer architecture (Route → Controller → Service)
- ✅ Routes only handle HTTP, no business logic
- ✅ Controllers return standardized
{success, data, error}structure - ✅ Services handle external API calls
- ✅ Never skipping layers
- ✅ TypeScript types defined in
shared/types.ts - ✅ Environment variables used for secrets
- ✅ Error handling at each layer
- ✅ Validation in controllers
- ✅ Sensitive data filtered before returning
- ✅ Top-level README updated with new features (backend/frontend/shared)
CRITICAL: When adding or modifying code, ALWAYS update the relevant top-level README.md file.
- Adding new controller functions
- Adding new service integrations
- Adding new routes or middleware
- Adding utility functions to
backend/utils/ - Adding cron handlers to
backend/crons/ - Adding email triggers or utilities to
backend/email/ - Changing error handling patterns
- Modifying authentication
- Adding new React components
- Changing styling patterns
- Adding state management patterns
- Modifying API integration
- Adding new hooks or utilities
- Adding new TypeScript interfaces to
types.ts - Adding utility functions to
utils.ts - Changing data structures
- Adding common patterns
- Changing overall architecture
- Adding major features
- Modifying environment variables
- Changing deployment process
Remember: Do NOT create README files in subdirectories. All documentation goes in top-level READMEs only.
IMPORTANT: AGENTS.md is the authoritative source for Val Town platform patterns. Always reference it for platform-specific details.
Use AGENTS.md for:
- Val Town platform requirements (triggers, redirects, errors, etc.)
- Deno-specific patterns and limitations
- Standard library usage (blob, SQLite, email, etc.)
- Val Town utility functions (serveFile, readFile, etc.)
- React configuration (pinning, jsxImportSource)
- Platform-specific import patterns
- Common gotchas and solutions
Use CLAUDE.md (this file) for:
- This val's specific architecture (MVC 3-layer)
- Controller/Service/Route patterns
- Notion integration patterns
- Authentication implementation
- File organization beyond basic structure
- Response formats and error handling strategy
- Import pattern exceptions (npm: for backend performance)
- Pico CSS styling (diverges from AGENTS.md Tailwind recommendation)
main.http.tsx is the HTTP entry point:
- Exports
app.fetchfor Val Town - Uses Hono for routing
- Serves static frontend assets
- Mounts API routes at
/api - Includes error handler
Always mount new routes in main.http.tsx:
import { taskRoutes } from "./backend/routes/tasks/index.ts";
app.route("/tasks", taskRoutes);
This val uses Hono as the web framework. Follow these patterns:
import { Hono } from "npm:hono@4";
import { Context, Next } from "npm:hono@4";
Note: Use npm: specifier for backend-only packages (see Import Patterns section for rationale).
When creating route modules (e.g., backend/routes/api/pages.ts):
import { Hono } from "npm:hono@4";
import * as pageController from "../../controllers/pageController.ts";
const pages = new Hono();
// GET /api/pages/:id
pages.get("/:id", async (c) => {
const pageId = c.req.param("id");
const result = await pageController.getPage(pageId);
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
return c.json(result.data);
});
// POST /api/pages/:id
pages.post("/:id", async (c) => {
const pageId = c.req.param("id");
const body = await c.req.json();
const result = await pageController.updatePage(pageId, body.properties);
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
return c.json(result.data);
});
export default pages;
Create an index file for each route directory (e.g., backend/routes/api/index.ts):
import { Hono } from "npm:hono@4";
import pages from "./pages.ts";
const api = new Hono();
api.route("/pages", pages);
// Add more routes as needed
export default api;
Then mount in main.http.tsx:
import api from "./backend/routes/api/index.ts";
app.route("/api", api);
// URL params
const id = c.req.param("id");
// Query params
const query = c.req.query("q");
// Headers
const apiKey = c.req.header("x-api-key");
// JSON body
const body = await c.req.json();
// Form data
const formData = await c.req.formData();
// JSON response
return c.json({ data: "value" });
// JSON with status code
return c.json({ error: "Not found" }, 404);
// HTML response
return c.html("<h1>Hello</h1>");
// Plain text
return c.text("Hello");
// Redirect - see AGENTS.md for Val Town redirect workaround
Authentication is applied globally in main.http.tsx (see Authentication Rules section).
For other middleware needs (logging, rate limiting, etc.), you can apply per-route:
// Apply to single route
pages.post("/:id", someMiddleware, async (c) => {
// Route logic
});
// Apply to all routes in this module
pages.use("*", someMiddleware);
Already configured in main.http.tsx - see Step 2 and AGENTS.md for patterns.
backend/routes/
├── api/
│ ├── index.ts # Mounts all API routes
│ ├── pages.ts # /api/pages/* endpoints
│ └── databases.ts # /api/databases/* endpoints
├── tasks/
│ ├── index.ts # Mounts all webhook routes
│ └── notion.ts # /tasks/notion webhook handler
├── views/
│ └── dashboard.ts # /views/dashboard page
└── authCheck.ts # Auth middleware