JSON in Deno Fresh: Handlers, Islands, and Deno KV
Last updated:
Deno Fresh is a web framework for Deno that uses islands architecture — most of the page is rendered as static HTML on the server, and only interactive components ("islands") ship JavaScript to the browser. JSON flows through Fresh in 3 ways: route handlers return JSON responses for API endpoints, data from the page handler function is passed as props to page components (serialized as JSON), and islands fetch JSON client-side via the standard fetch API. Define an API route in routes/api/products.ts with an exported handler object:
export const handler: Handlers = {
async GET(req, ctx) {
return new Response(JSON.stringify(products), {
headers: { 'Content-Type': 'application/json' }
})
}
}Fresh also exports a json helper from $fresh/server.ts that sets the correct headers. For page data, handler.GET returns ctx.render(data) — the data is serialized to JSON and passed as props to the page component. Deno KV (Deno.openKv()) provides built-in key-value storage with structured-clone-serializable values. This guide covers API route JSON handlers, page handler data flow, Deno KV JSON storage, island client fetching, Zod validation, and TypeScript types.
API Route JSON Handlers
Bottom line: files in routes/api/ export a handler object with HTTP method functions. Use the json() helper from $fresh/server.ts to return JSON with a single call — it calls JSON.stringify and sets Content-Type: application/json automatically.
Each method function receives a standard Request object and a HandlerContext. Path parameters come from the filename: a file at routes/api/products/[id].ts receives ctx.params.id as a string. For POST requests, parse the incoming body with await req.json() — this throws a SyntaxError if the body is not valid JSON, so wrap it in a try/catch. Error responses follow the same pattern: pass a status option as the second argument to json(). A _middleware.ts file in routes/api/ runs on every API request and is the right place to add CORS headers or authentication checks.
// routes/api/products.ts
import { Handlers } from "$fresh/server.ts"
import { json } from "$fresh/server.ts"
interface Product {
id: number
name: string
price: number
}
// In-memory data — replace with Deno KV or a database in production
const products: Product[] = [
{ id: 1, name: "Widget", price: 9.99 },
{ id: 2, name: "Gadget", price: 24.50 },
]
export const handler: Handlers = {
// GET /api/products
async GET(req, ctx) {
return json(products)
// Equivalent to:
// return new Response(JSON.stringify(products), {
// headers: { "Content-Type": "application/json" },
// })
},
// POST /api/products
async POST(req, ctx) {
let body: unknown
try {
body = await req.json()
} catch {
return json({ error: "Invalid JSON body" }, { status: 400 })
}
// Validate and create (validation shown in Section 3)
const newProduct = body as Product
products.push(newProduct)
return json(newProduct, { status: 201 })
},
}
// routes/api/products/[id].ts — path parameter
export const handler: Handlers = {
async GET(req, ctx) {
const id = Number(ctx.params.id) // ctx.params.id is always a string
const product = products.find(p => p.id === id)
if (!product) {
return json({ error: "Not found" }, { status: 404 })
}
return json(product)
},
}
// routes/api/_middleware.ts — CORS headers for every API route
import { FreshContext } from "$fresh/server.ts"
export async function handler(req: Request, ctx: FreshContext) {
const resp = await ctx.next()
const headers = new Headers(resp.headers)
headers.set("Access-Control-Allow-Origin", "*")
headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
return new Response(resp.body, { status: resp.status, headers })
}Page Handler Data Flow: ctx.render()
Bottom line: a Fresh page route can export both a handler (server logic) and a default component (render). The handler calls ctx.render(data) to pass data to the component — Fresh serializes it to JSON, embeds it in the HTML, and the component receives it as the data prop via PageProps<T>.
The Handlers<T> generic type parameter must match the PageProps<T> type parameter — this ensures the handler and component agree on the data shape at compile time. Fresh serializes the ctx.render() argument to JSON when generating the HTML page, which means 3 constraints apply: Date objects are converted to ISO strings (not back to Date on the client),undefined values are dropped (use null), and Map, Set, and class instances are not JSON-serializable. Convert to plain objects or arrays before calling ctx.render().
// routes/products.tsx
import { Handlers, PageProps } from "$fresh/server.ts"
interface Product {
id: number
name: string
price: number
createdAt: string // ISO string — not Date (Date is not JSON-serializable)
}
// handler runs on the server — fetches data and calls ctx.render()
export const handler: Handlers<Product[]> = {
async GET(req, ctx) {
// Fetch from an API, Deno KV, or a database
const res = await fetch("https://api.example.com/products")
const products: Product[] = await res.json()
// ctx.render() serializes products to JSON and passes it to the component
return ctx.render(products)
},
}
// Default export component receives data via PageProps<T>
export default function ProductsPage({ data }: PageProps<Product[]>) {
return (
<main>
<h1>Products ({data.length})</h1>
<ul>
{data.map((product) => (
<li key={product.id}>
{product.name} — ${product.price.toFixed(2)}
</li>
))}
</ul>
</main>
)
}
// Avoid these patterns — they break ctx.render() serialization:
// ❌ return ctx.render({ date: new Date() }) — Date is not JSON-serializable
// ✅ return ctx.render({ date: new Date().toISOString() })
// ❌ return ctx.render({ items: new Map([["a", 1]]) }) — Map is not JSON-serializable
// ✅ return ctx.render({ items: Object.fromEntries(map) })
// ❌ return ctx.render({ value: undefined }) — undefined is dropped by JSON.stringify
// ✅ return ctx.render({ value: null })Zod Validation for Incoming JSON
Bottom line: parse the request body with req.json(), then validate with a Zod schema using schema.safeParse(body). Return a 400 response with structured field errors on validation failure. Because Fresh runs on Deno with native TypeScript, no build step is required.
Add Zod to deno.json with the npm: specifier: "zod": "npm:zod@3". Use z.infer<typeof schema> to derive the TypeScript type from the schema — no need to write the interface separately. The safeParse method never throws; it returns either { success: true, data } or { success: false, error }. Call result.error.flatten() to get a { fieldErrors, formErrors } object that you can return directly to clients. Sharing the same Zod schema between the server handler and an island component gives consistent validation on both sides: the handler enforces it server-side, and the island can show the same error messages client-side before sending a request.
// routes/api/products.ts
import { Handlers } from "$fresh/server.ts"
import { json } from "$fresh/server.ts"
import { z } from "zod"
// Define schema once — share with island components for client-side validation
export const productSchema = z.object({
name: z.string().min(1).max(255),
price: z.number().positive(),
category: z.enum(["electronics", "clothing", "food"]),
tags: z.array(z.string()).max(10).optional(),
})
// Derive the TypeScript type from the schema
export type ProductInput = z.infer<typeof productSchema>
export const handler: Handlers = {
async POST(req, ctx) {
// Step 1: Parse the raw body (throws SyntaxError if not valid JSON)
let body: unknown
try {
body = await req.json()
} catch {
return json({ error: "Request body must be valid JSON" }, { status: 400 })
}
// Step 2: Validate shape and types
const result = productSchema.safeParse(body)
if (!result.success) {
return json(
{ errors: result.error.flatten() },
{ status: 400 }
)
// Response shape: { errors: { fieldErrors: { name: [...], price: [...] }, formErrors: [] } }
}
// result.data is typed as ProductInput — safe to use
const product = result.data
const saved = await saveProduct(product) // your KV or DB call
return json(saved, { status: 201 })
},
}
// --- Using the same schema in an island (islands/AddProduct.tsx) ---
// import { productSchema } from "../routes/api/products.ts"
//
// export default function AddProduct() {
// const [errors, setErrors] = useState<z.inferFlattenedErrors<typeof productSchema> | null>(null)
//
// async function handleSubmit(formData: FormData) {
// const input = Object.fromEntries(formData)
// const result = productSchema.safeParse(input)
// if (!result.success) { setErrors(result.error.flatten()); return }
// await fetch("/api/products", { method: "POST", body: JSON.stringify(result.data) })
// }
// }
async function saveProduct(p: ProductInput) {
return { id: crypto.randomUUID(), ...p }
}Deno KV for JSON Persistence
Bottom line: Deno.openKv() provides a built-in key-value store with no external dependencies. Values are stored using the structured clone algorithm — not JSON.stringify — so Date, Map, Set, and ArrayBuffer are supported natively. Each value is limited to 64 KB.
Keys are typed arrays of string | number | bigint | boolean | Uint8Array — for example ["products", "123"]. The kv.list() method returns an async iterator over all entries matching a key prefix, making it suitable for listing collections. Atomic operations via kv.atomic() let you check a key's versionstamp before writing — if another process updated the key between your read and write, the commit() returns null and you retry. Open 1 KV instance per application and reuse it — opening multiple instances is wasteful. On Deno Deploy, Deno.openKv() connects to a globally replicated FoundationDB-backed store; locally it uses SQLite.
// lib/kv.ts — open once, reuse everywhere
const kv = await Deno.openKv()
export { kv }
// routes/api/products.ts
import { kv } from "../lib/kv.ts"
import { Handlers } from "$fresh/server.ts"
import { json } from "$fresh/server.ts"
interface Product {
id: string
name: string
price: number
createdAt: Date // Date is supported by structured clone — no ISO string needed
}
export const handler: Handlers = {
// GET /api/products — list all products
async GET(req, ctx) {
const products: Product[] = []
const iter = kv.list<Product>({ prefix: ["products"] })
for await (const entry of iter) {
products.push(entry.value)
}
return json(products)
},
// POST /api/products — create a product
async POST(req, ctx) {
const body = await req.json()
const id = crypto.randomUUID()
const product: Product = {
id,
name: body.name,
price: body.price,
createdAt: new Date(), // stored as Date, not string
}
await kv.set(["products", id], product)
return json(product, { status: 201 })
},
}
// GET /api/products/[id].ts — read a single product
export const handler: Handlers = {
async GET(req, ctx) {
const result = await kv.get<Product>(["products", ctx.params.id])
if (result.value === null) {
return json({ error: "Not found" }, { status: 404 })
}
return json(result.value)
// result.versionstamp — use this for optimistic concurrency checks
},
// DELETE /api/products/[id].ts
async DELETE(req, ctx) {
await kv.delete(["products", ctx.params.id])
return new Response(null, { status: 204 })
},
}
// Atomic update — check versionstamp to prevent lost writes
async function updateProduct(id: string, updates: Partial<Product>) {
while (true) {
const result = await kv.get<Product>(["products", id])
if (result.value === null) throw new Error("Product not found")
const updated = { ...result.value, ...updates }
const commit = await kv
.atomic()
.check({ key: ["products", id], versionstamp: result.versionstamp })
.set(["products", id], updated)
.commit()
if (commit !== null) return updated // success
// commit === null means versionstamp mismatch — retry
}
}Islands: Client-Side JSON Fetching
Bottom line: only files in the islands/ directory hydrate in the browser. Fetch JSON from API routes using the standard fetch API inside useEffect. Fresh uses Preact (not React) — for reactive state, prefer Preact signals over useStatewhen multiple components need the same value.
Fresh's constraint is firm: components outside islands/ are server-only — they render to HTML and ship 0 bytes of JavaScript. This means the total client bundle size equals the sum of your island components only. For a product list that does not need interactivity, keep it in a regular component fed by ctx.render() (see Section 2). Use an island when you need user interaction: search filtering, real-time updates, optimistic UI, or infinite scroll. Preact signals (signal()from @preact/signals) are more efficient than useState for values shared across multiple islands because only the DOM nodes that read the signal update — not the full component tree. The fetch API is available in all modern browsers with no polyfill; Deno also supports it natively if you need to call it from a server handler.
// islands/ProductList.tsx — hydrates in the browser
import { useEffect, useState } from "preact/hooks"
import { signal, computed } from "@preact/signals"
interface Product {
id: string
name: string
price: number
}
// --- useState approach (component-local state) ---
export default function ProductList() {
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch("/api/products")
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json() as Promise<Product[]>
})
.then((data) => {
setProducts(data)
setLoading(false)
})
.catch((err) => {
setError(err.message)
setLoading(false)
})
}, [])
if (loading) return <p>Loading {products.length} products...</p>
if (error) return <p>Error: {error}</p>
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} — ${p.price.toFixed(2)}
</li>
))}
</ul>
)
}
// --- Preact signals approach (shared across islands) ---
// shared-state.ts (imported by multiple islands)
export const productsSignal = signal<Product[]>([])
export const loadingSignal = signal(true)
export const countSignal = computed(() => productsSignal.value.length)
// islands/ProductCount.tsx — reads the same signal, no prop drilling
import { countSignal, loadingSignal, productsSignal } from "../shared-state.ts"
export default function ProductCount() {
useEffect(() => {
fetch("/api/products")
.then((r) => r.json())
.then((data: Product[]) => {
productsSignal.value = data
loadingSignal.value = false
})
}, [])
// Only this span re-renders when countSignal changes — not the full component
return <span>{loadingSignal.value ? "..." : `${countSignal.value} products`}</span>
}TypeScript and Deno Module System
Bottom line: Deno has built-in TypeScript support — no tsconfig.json or compilation step for basic Fresh projects. Manage dependencies in deno.json with an imports map. The Handlers<DataType, StateType> generic types both page data and middleware state.
Fresh generates fresh.gen.ts automatically on every deno task start — this file maps all routes and islands and must be committed to the repository. Run deno check routes/api/products.ts to type-check a specific file without starting the dev server; run deno check **/*.ts in CI to check the entire project. The StateType parameter in Handlers<D, S> corresponds to ctx.state, which middleware populates — use it to pass authentication context (user ID, session token) from _middleware.ts to route handlers without global variables. Deno's module resolution is URL-based: the same file URL at the same version always resolves to the same bytes — no node_modules directory, no hoisting, no phantom dependencies.
// deno.json — project configuration and import map
{
"tasks": {
"start": "deno run -A --watch=static/,routes/ dev.ts",
"check": "deno check **/*.ts"
},
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.6.0/",
"preact": "https://esm.sh/preact@10.19.2",
"preact/": "https://esm.sh/preact@10.19.2/",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.1",
"zod": "npm:zod@3"
}
}
// routes/api/products.ts — Handlers generic with StateType
import { Handlers, FreshContext } from "$fresh/server.ts"
import { json } from "$fresh/server.ts"
// StateType: set by _middleware.ts and read in handlers
interface State {
userId: string
role: "admin" | "user"
}
interface Product {
id: string
name: string
price: number
}
// Handlers<DataType, StateType> — DataType is the ctx.render() type (unused in API routes)
export const handler: Handlers<unknown, State> = {
async GET(req, ctx) {
// ctx.state.userId is typed as string (set by middleware)
const userId = ctx.state.userId
const products = await fetchProductsForUser(userId)
return json(products)
},
}
// routes/_middleware.ts — sets ctx.state for all routes
export async function handler(req: Request, ctx: FreshContext<State>) {
const token = req.headers.get("Authorization")?.replace("Bearer ", "")
if (!token) return json({ error: "Unauthorized" }, { status: 401 })
// Validate token and populate ctx.state
ctx.state.userId = await validateToken(token)
ctx.state.role = "user"
return ctx.next()
}
// fresh.gen.ts — auto-generated, do not edit manually
// This file is committed to the repository and regenerated on dev server start.
// import * as $0 from "./routes/_middleware.ts"
// import * as $1 from "./routes/api/products.ts"
// import * as $2 from "./routes/api/products/[id].ts"
// import * as $3 from "./islands/ProductList.tsx"
// const manifest = { routes: { ... }, islands: { ... }, baseUrl: import.meta.url }
// export default manifest
// deno check command — type-check without running the server
// $ deno check routes/api/products.ts — check one file
// $ deno check **/*.ts — check all TypeScript files
// $ deno fmt — format code (no Prettier needed)
// $ deno lint — lint code (no ESLint needed)
async function fetchProductsForUser(userId: string): Promise<Product[]> {
return []
}
async function validateToken(token: string): Promise<string> {
return "user-123"
}FAQ
How do I create a JSON API route in Deno Fresh?
Create a file in routes/api/ and export a handler object with HTTP method functions. Use the json() helper from $fresh/server.ts — it calls JSON.stringify and sets Content-Type: application/json in 1 call. For POST requests, parse the body with await req.json() (wrap in try/catch for malformed JSON). Path parameters come from the filename: routes/api/products/[id].ts provides ctx.params.id. Return error responses with a status option: json({ error: "Not found" }, { status: 404 }). Add CORS headers in a _middleware.ts file in the routes/api/ directory.
How do I pass JSON data to a Fresh page component?
Add a handler export alongside the default component export. In handler.GET, fetch your data and return ctx.render(data) — Fresh serializes it to JSON and passes it to the component as the data prop via PageProps<T>. The data must be JSON-serializable: use ISO strings instead of Date objects, null instead of undefined, and plain objects or arrays instead of Map or Set. The Handlers<T> generic and PageProps<T> must use the same T.
How do I store JSON in Deno KV?
Open a KV store with const kv = await Deno.openKv(). Keys are arrays of strings or numbers — for example ["products", "123"]. Store a value with await kv.set(["products", "123"], { name: "Widget" }). Deno KV uses structured clone, not JSON.stringify — Date, Map, and Set are supported. Read with await kv.get(["products", "123"]) — result.value is the stored object or null. List a collection with kv.list({ prefix: ["products"] }). Each value is limited to 64 KB. Use kv.atomic().check().set().commit() for concurrent-safe updates.
How does Fresh islands architecture affect JSON fetching?
Only files in the islands/ directory hydrate in the browser — all other components are server-only with 0 bytes of client JavaScript. API routes therefore have no client-side bundle overhead from page components. To fetch JSON in the browser, create an island component and use useEffect with the standard fetch API. Fresh uses Preact, not React — the hook API is identical, but import from "preact/hooks". For reactive state shared across multiple islands, use Preact signals (import { signal } from "@preact/signals") — only the DOM nodes reading a signal update when it changes.
How do I validate JSON in a Deno Fresh handler?
Parse the body with const body = await req.json() (throws on invalid JSON — use try/catch). Validate with const result = schema.safeParse(body). On failure, return a 400 response: json({ errors: result.error.flatten() }, { status: 400 }).result.error.flatten() produces a { fieldErrors, formErrors } object. Use z.infer<typeof schema> to derive the TypeScript type. Add Zod to deno.json with "zod": "npm:zod@3" — no compilation step needed.
How do I use TypeScript with Deno Fresh?
Deno has built-in TypeScript support — no tsconfig.json or compilation step for basic projects. Manage dependencies in deno.json with an imports map: "zod": "npm:zod@3". The Handlers<DataType, StateType> generic from $fresh/server.ts types both the ctx.render() argument and the ctx.state object set by middleware. Run deno check routes/api/products.ts to type-check without starting the dev server. Fresh auto-generates fresh.gen.ts — commit this file. Use deno fmt and deno lint without additional tooling.
Key Terms
handler object- An exported object in a Fresh route file with HTTP method functions (
GET,POST,PUT,DELETE). Each function receives aRequestand aHandlerContextand must return aResponse. ctx.render()- A method on
HandlerContextthat serializes its argument to JSON, embeds it in the server-rendered HTML, and passes it as thedataprop to the page component. The argument must be JSON-serializable. PageProps<T>- The prop type for Fresh page components. The
datafield has typeT, matching the value passed toctx.render(). Also providesurl,route, andparams. Deno KV- A built-in key-value store opened with
Deno.openKv(). Keys are typed arrays; values are stored using structured clone (supportsDate,Map,Set). Limited to 64 KB per value. On Deno Deploy, backed by FoundationDB with global replication. island component- A Preact component in the
islands/directory that hydrates in the browser. All other components are server-only with 0 client JavaScript. Islands can useuseEffectandfetchfor client-side JSON fetching. deno.json- The Deno project configuration file. Contains the
importsmap for dependency management,tasksfor script aliases, and compiler options. Replacespackage.jsonandtsconfig.jsonfor Deno projects.
Format and validate your JSON
Use the free JSON formatter and validator at jsonic.io to check your API responses, Deno KV exports, and Zod-validated payloads instantly in the browser.
Open JSON FormatterFurther reading and primary sources
- Deno Fresh documentation — Official Fresh framework documentation covering route handlers, islands architecture, middleware, and deployment to Deno Deploy
- Deno KV documentation — Official Deno KV manual covering key structure, get/set/list operations, atomic transactions, and the 64 KB value limit
- Deno Built-in TypeScript — Official Deno guide to built-in TypeScript support, deno check, deno.json configuration, and the difference from Node.js TypeScript toolchains
- Preact Signals — Preact signals documentation for reactive state management in Fresh islands — an alternative to useState that only re-renders the DOM nodes reading the signal
- Zod documentation — Zod schema validation library used for validating incoming JSON in Fresh route handlers — safeParse, flatten, and z.infer for TypeScript type derivation