Val Town
Overview
Val Town is a serverless platform that runs TypeScript/TSX on Deno. A val is a versioned folder of files (now multi-file by default) that can expose HTTP, Cron, or Email triggers. Every val gets a private SQLite database, blob storage, and free access to cheap OpenAI models.
Core mental model: Deno + multi-file project + suffix-based trigger detection. Edit locally with
, watch syncs to production.
Quick Reference
| Task | Approach |
|---|
| Create val | vt create my-val && cd my-val
|
| Clone val | vt clone username/valName
|
| Live sync | (preferred dev loop) |
| One-shot push | |
| Browse online | |
| Tail logs | |
| HTTP endpoint | filename ends in (or ) |
| Cron job | filename ends in |
| Email handler | filename ends in |
| Plain module | any other / |
| Free LLM | import { OpenAI } from "https://esm.town/v/std/openai"
→ model |
| Per-val SQLite | import { sqlite } from "https://esm.town/v/std/sqlite/main.ts"
|
| Blob storage | import { blob } from "https://esm.town/v/std/blob"
|
| Send email | import { email } from "https://esm.town/v/std/email"
|
| NPM import | import x from "npm:package@version"
|
| JSR import | import x from "jsr:@scope/pkg"
|
| Browser-safe | import x from "https://esm.sh/package@version"
|
| Node builtin | import { x } from "node:crypto"
|
| Env var | (set in val Settings → Env vars) |
Multi-File Vals (Preferred)
Vals support up to 1000 files. Multi-file is the default and recommended approach — split logic by responsibility.
Trigger detection by filename
The file's name suffix determines its trigger type:
| Filename pattern | Trigger | Default export signature |
|---|
| / | HTTP | (req: Request) => Response | Promise<Response>
|
| / | Cron | (interval: Interval) => unknown
|
| / | Email | (email: Email) => unknown
|
| anything else (/) | script/module | regular ESM module |
Recommended layout
my-val/
├── deno.json # Deno config (auto-generated by vt)
├── main.http.tsx # HTTP entry point
├── daily.cron.tsx # Scheduled job
├── inbox.email.tsx # Email handler
├── backend/
│ ├── db.ts # SQLite queries
│ └── routes.ts # Hono routes
├── frontend/
│ ├── index.html
│ ├── index.tsx
│ └── style.css
└── shared/
└── types.ts # Types used on server + client
Importing between files
Use relative paths — they survive branching, remixing, and renaming:
ts
import { fetchUser } from "./backend/db.ts";
import type { User } from "./shared/types.ts";
Do not import other files via the
URL inside the same val — relative imports are correct.
Serving frontend assets from an HTTP val
ts
import { readFile, serveFile } from "https://esm.town/v/std/utils/index.ts";
import { Hono } from "npm:hono";
const app = new Hono();
app.get("/frontend/*", c => serveFile(c.req.path, import.meta.url));
app.get("/shared/*", c => serveFile(c.req.path, import.meta.url));
app.get("/", async c => {
const html = await readFile("/frontend/index.html", import.meta.url);
return c.html(html);
});
export default app.fetch;
/
/
/
are
server-only — they don't run in the browser.
OpenAI for Free
Val Town proxies OpenAI for you — no API key required for cheap models.
ts
import { OpenAI } from "https://esm.town/v/std/openai";
const openai = new OpenAI();
const completion = await openai.chat.completions.create({
model: "gpt-5-nano", // free tier
messages: [{ role: "user", content: "say hi" }],
max_tokens: 100,
});
console.log(completion.choices[0].message.content);
Free models (output < $1 / Mtok):
,
,
.
Pro tier: 10 expensive requests / 24h rolling window, then bumped to
.
Limits: 10 requests / minute per user. Chat completions endpoint only.
Bring your own key: Set
env var and
import { OpenAI } from "npm:openai"
instead.
SQLite (Val-Scoped)
Every val ships with a private SQLite database, powered by Turso/libSQL. 10 MB free, 1 GB on Pro.
ts
import { sqlite } from "https://esm.town/v/std/sqlite/main.ts";
await sqlite.execute(`
create table if not exists notes (
id integer primary key autoincrement,
body text not null,
created_at text default current_timestamp
)
`);
await sqlite.execute({
sql: `insert into notes (body) values (?)`,
args: ["hello"],
});
const result = await sqlite.execute(`select * from notes order by id desc limit 10`);
console.log(result.rows);
Critical:
- Import path matters: = val-scoped (preferred). Bare = legacy account-scoped global DB.
- exists for transactions:
sqlite.batch(["sql1", "sql2"])
.
- Limited support. When changing schema, create a new table (, ) and migrate data rather than altering in place.
- Always run
create table if not exists
before queries.
- ORMs work: Drizzle and others over libSQL — see
https://docs.val.town/reference/std/sqlite/orms
.
Blob Storage
Account-scoped key-value store on Cloudflare R2. Use it for binary data (images, PDFs) since vals can't write the filesystem.
ts
import { blob } from "https://esm.town/v/std/blob";
await blob.setJSON("config", { theme: "dark" });
const config = await blob.getJSON("config");
const keys = await blob.list("user_"); // prefix list
await blob.delete("config");
Don't use Deno KV — it isn't supported for storage.
Importing Anything via CDN
Val Town runs on Deno, so HTTP imports are first-class. Pin versions to avoid surprise breaks.
| Source | Syntax |
|---|
| NPM | import { z } from "npm:zod@3.23.8"
|
| JSR | import { concat } from "jsr:@std/bytes@1.0.2"
|
| esm.sh | import React from "https://esm.sh/react@18.2.0"
|
| deno.land/x | import { Hono } from "https://deno.land/x/hono@v4.5.0/mod.ts"
|
| Node builtin | import { createHmac } from "node:crypto"
|
| Other val | import { x } from "https://esm.town/v/user/valName"
|
When to prefer over : code that runs in the browser, or NPM modules that try to touch the filesystem (sandbox blocks fs access — esm.sh often patches around it).
React pinning: ALL React + ReactDOM versions must match. Use
?deps=react@18.2.0,react-dom@18.2.0
and put
/** @jsxImportSource https://esm.sh/react@18.2.0 */
at the top of any TSX file.
ts
import { useState } from "https://esm.sh/react@18.2.0?deps=react@18.2.0";
Cron Jobs
Name a file
and the platform schedules it. Free tier: every 15 min minimum. Pro: every 1 min.
ts
// daily.cron.tsx
export default async function (interval: Interval) {
console.log("last ran at:", interval.lastRunAt);
// do work
}
The schedule itself (cron expression or simple interval) is configured in the
val editor UI (
button → CRON), not in code. Crons run in
UTC — adjust expressions for local time.
Common expressions:
| Expression | Meaning |
|---|
| every hour on the hour |
| every 15 min (free-tier minimum) |
| 09:00 UTC daily |
| Mondays at 00:00 UTC |
is
— undefined on the first run. Use it to fetch only items newer than the last run.
Sending Email
ts
import { email } from "https://esm.town/v/std/email";
await email({
subject: "deploy succeeded",
text: "everything green",
html: "<h1>everything green</h1>",
});
By default the message goes to the val owner. The free tier allows email
out; receiving requires an
handler on a paid plan.
Deno + TypeScript Best Practices
deno.json
generates this for you. Keep
in
so editor sees
,
, etc.
json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": false,
"types": ["https://www.val.town/types/valtown.d.ts"],
"lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns", "deno.unstable"]
},
"node_modules_dir": false,
"experimental": {
"unstable-temporal": true,
"unstable-sloppy-imports": true
}
}
Runtime constraints
- No filesystem writes. No , no module. Use blob storage or SQLite.
- No binary files in the val itself — only text. Store binaries in blob.
- is on; works for any URL.
- Experimental Temporal API is available.
- No / / (no UI process).
- No Deno KV.
- is broken — return
new Response(null, { status: 302, headers: { Location: "/x" } })
instead.
Code style
- Prefer functional ES2020+ — , arrow fns, destructuring.
- Add explicit types for exported functions and shared interfaces.
- Use only when you can resolve the error locally; otherwise let it bubble for full stack traces.
- For Hono apps add an unwrapping error handler so you see real stack traces:
ts
app.onError((_err, _c) => { throw _err; });
- Code in runs in both server and browser — never reference there. Use imports if it must work in both.
- Secrets only via — never bake keys into source.
Debugging client-side
Drop this into your HTML head to surface client-side errors back to val logs:
html
<script src="https://esm.town/v/std/catch"></script>
CLI: vt
vt create <name> [dir] # new val (--private, --unlisted, --org-name)
vt clone user/val [dir] # clone existing
vt watch # auto-sync local edits to prod (best dev loop)
vt push # one-shot upload
vt pull # download remote changes
vt status # working-tree diff
vt branch / checkout # branch ops
vt tail [user/val] # stream live logs
vt browse # open val in browser
vt list [-a] # list your vals
vt remix <user/val> # fork
vt config set <k> <v> # config (run `vt config options` to list)
is the recommended workflow: edit in your editor, changes deploy in <1s, logs stream with
in another pane.
Common Mistakes
| Mistake | Fix |
|---|
| Trigger file not detected | Filename must match / / exactly |
| returns blank | Use new Response(null, { status: 302, headers: { Location: "/x" } })
|
| React renders nothing / hooks error | All React deps must pin same version; add ?deps=react@18.2.0,react-dom@18.2.0
|
| fails | Create new table with version suffix and migrate |
| returns nothing in browser | It's server-only — call from server, inject into HTML |
| NPM module crashes in sandbox | Module probably touched filesystem; switch to |
| Cron fires more often than expected | You set local-time expression; crons are UTC |
| Importing same-val file via URL | Use relative instead |
| Storing binary in a val file | Vals are text-only; use (or raw blob API for bytes) |
| doesn't persist | Env vars are read-only at runtime; set in val Settings UI |
When NOT to Use Val Town
- Long-running compute (> request timeout) → use a proper VM or Cloud Run.
- Heavy filesystem work → no fs available.
- Large binary processing → 10 MB SQLite cap on free tier; blob is fine for storage but not for in-memory crunching.
- Workloads needing custom Deno permissions → can't change the sandbox.
Reference Links