Juniper doesn't prescribe a specific database solution, giving you the flexibility to choose what works best for your project. This guide covers Deno KV (built into Deno) and PostgreSQL (with Drizzle ORM and Zod), followed by general data-access patterns. Juniper works with any database that has a Deno-compatible driver.
Deno KV is a key-value database built directly into Deno, requiring no external services for development. It provides a simple yet powerful API for storing and retrieving data.
Open a KV database connection:
// services/db.ts
let kv: Deno.Kv | undefined;
export async function getKv(): Promise<Deno.Kv> {
if (!kv) {
kv = await Deno.openKv();
}
return kv;
}
export function closeKv(): void {
kv?.close();
kv = undefined;
}For testing, use an in-memory database:
import { isTest } from "@udibo/juniper/utils/env";
export async function getKv(): Promise<Deno.Kv> {
if (!kv) {
kv = await (isTest() ? Deno.openKv(":memory:") : Deno.openKv());
}
return kv;
}Setting and Getting Values:
const kv = await getKv();
// Set a value
await kv.set(["users", "user-123"], {
id: "user-123",
name: "Alice",
email: "alice@example.com",
});
// Get a value
const entry = await kv.get(["users", "user-123"]);
if (entry.value) {
console.log(entry.value.name); // "Alice"
}
// Delete a value
await kv.delete(["users", "user-123"]);Listing Values:
const kv = await getKv();
// List all users
const entries = kv.list({ prefix: ["users"] });
const users = [];
for await (const entry of entries) {
users.push(entry.value);
}
// List with options
const recentEntries = kv.list(
{ prefix: ["users"] },
{ limit: 10, reverse: true },
);Keys and Indexing:
Use structured keys for efficient querying:
// Primary key by ID
await kv.set(["users", "id", user.id], user);
// Secondary index by email (for unique lookups)
await kv.set(["users", "email", user.email], user);
// Secondary index for non-unique values (include ID in key)
await kv.set(["posts", "authorId", post.authorId, post.id], post);Use atomic operations to ensure data consistency:
const kv = await getKv();
// Atomic transaction with optimistic locking
const existing = await kv.get(["users", "id", userId]);
if (!existing.value) {
throw new Error("User not found");
}
const result = await kv.atomic()
// Check that the value hasn't changed
.check(existing)
// Update the primary entry
.set(["users", "id", userId], updatedUser)
// Update secondary indexes
.set(["users", "email", updatedUser.email], updatedUser)
// Delete old secondary index if email changed
.delete(["users", "email", existing.value.email])
.commit();
if (!result.ok) {
throw new Error("Transaction failed - data was modified");
}Creating with Unique Constraints:
async function createUser(user: User): Promise<User> {
const kv = await getKv();
const result = await kv.atomic()
// Ensure email doesn't already exist
.check({ key: ["users", "email", user.email], versionstamp: null })
// Ensure username doesn't already exist
.check({ key: ["users", "username", user.username], versionstamp: null })
// Set primary and secondary entries
.set(["users", "id", user.id], user)
.set(["users", "email", user.email], user)
.set(["users", "username", user.username], user)
.commit();
if (!result.ok) {
throw new HttpError(400, "User with this email or username already exists");
}
return user;
}For relational data, pair Juniper with PostgreSQL
using Drizzle ORM — a typed query builder with a
migration toolkit — and the pg (node-postgres) driver. Validate untrusted
input with Zod.
A complete, runnable version of everything in this section ships as the
postgrestemplate (templates/postgres). It implements a small guestbook so you can see the full read/write loop. Clone it withdeno run -A npm:degit udibo/juniper/templates/postgres my-app.
Add the database packages to your import map:
{
"imports": {
"drizzle-kit": "npm:drizzle-kit@^0.31.10",
"drizzle-orm": "npm:drizzle-orm@^0.45.2",
"pg": "npm:pg@^8.20.0",
"@types/pg": "npm:@types/pg@^8.20.0",
"zod": "npm:zod@^4.3.6"
}
}Use Docker Compose to run PostgreSQL for development and tests. Create a
docker-compose.yml:
services:
postgres:
image: postgres:18
container_name: juniper-postgres
ports:
- "5432:5432"
volumes:
- ./docker/volumes/postgres:/var/lib/postgresql
- ./docker/postgres/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stoppedExclude the volume from file watching. PostgreSQL writes its data into the bind-mounted
./docker/volumes/postgresdirectory asroot, which the dev server's file watcher cannot read. Watching it would crashdeno task devwith aPermissionDeniederror. Add abuild.tsthat ignores./dockerso the watcher skips it:// build.ts import * as path from "@std/path"; import { Builder } from "@udibo/juniper/build"; const projectRoot = path.dirname(path.fromFileUrl(import.meta.url)); export const builder = new Builder({ projectRoot, configPath: "./deno.json", ignorePaths: ["./docker"], }); if (import.meta.main) { await builder.build(); await builder.dispose(); }Then point your
build/build:prodtasks at./build.tsinstead of@udibo/juniper/build. See Watch Paths Configuration for details.
The mounted docker/postgres/init-db.sql creates the dev and test databases the
first time the container starts:
CREATE DATABASE app_dev;
CREATE DATABASE app_test;Point each environment at its own database. In .env:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app_dev
And in .env.test:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app_test
Define your tables with Drizzle. Columns are written in camelCase and mapped to
snake_case in PostgreSQL by the casing: "snake_case" option (so createdAt
becomes the created_at column):
// db/schema.ts
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
export const messages = pgTable("messages", {
id: serial().primaryKey(),
name: text().notNull(),
body: text().notNull(),
createdAt: timestamp({ precision: 3 }).defaultNow().notNull(),
});Create the Drizzle client from a node-postgres connection pool. The pool opens
no connection until the first query runs, so importing this module is cheap:
// db/mod.ts
import { getEnv } from "@udibo/juniper/utils/env";
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema.ts";
const databaseUrl = getEnv("DATABASE_URL");
if (!databaseUrl) {
throw new Error("DATABASE_URL is required");
}
export const db = drizzle({
connection: databaseUrl,
casing: "snake_case",
schema,
});
let closed = false;
/** Closes the pool. Call in a test's `afterAll` to release resources. */
export async function closeDb(): Promise<void> {
if (closed) return;
closed = true;
await db.$client.end();
}Configure Drizzle Kit with drizzle.config.ts:
import { defineConfig } from "drizzle-kit";
const url = Deno.env.get("DATABASE_URL");
if (!url) {
throw new Error("DATABASE_URL is required");
}
export default defineConfig({
schema: "./db/schema.ts",
out: "./db/drizzle",
dialect: "postgresql",
dbCredentials: { url },
casing: "snake_case",
});Add tasks for Docker and Drizzle Kit to deno.json:
{
"tasks": {
"docker:start": "docker compose up -d --wait",
"docker:stop": "docker compose down",
"db:generate": "deno run -A --env-file npm:drizzle-kit generate",
"db:migrate:dev": "deno run -A --env-file npm:drizzle-kit migrate",
"db:migrate:test": "deno run -A --env-file --env-file=.env.test npm:drizzle-kit migrate",
"db:migrate": { "dependencies": ["db:migrate:dev", "db:migrate:test"] },
"db:studio": "deno run -A --env-file npm:drizzle-kit studio"
}
}The workflow for a schema change is: edit db/schema.ts, generate a migration,
then apply it.
deno task docker:start # Start PostgreSQL
deno task db:generate # Generate SQL from schema changes
deno task db:migrate # Apply migrations to the dev and test databasesKeep queries in a service module of plain exported functions, and validate
untrusted input with Zod. Deriving the row type from the schema with
$inferSelect keeps the type in sync with the table:
// services/message.ts
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/db/mod.ts";
import { messages } from "@/db/schema.ts";
export type Message = typeof messages.$inferSelect;
export const newMessageSchema = z.object({
name: z.string().trim().min(1, "Name is required").max(100),
body: z.string().trim().min(1, "Message is required").max(1000),
});
export type NewMessage = z.infer<typeof newMessageSchema>;
export async function listMessages(): Promise<Message[]> {
return await db.select().from(messages).orderBy(desc(messages.createdAt));
}
export async function createMessage(data: NewMessage): Promise<Message> {
const [message] = await db.insert(messages).values(data).returning();
return message;
}
export async function deleteMessage(id: number): Promise<boolean> {
const deleted = await db.delete(messages).where(eq(messages.id, id))
.returning({ id: messages.id });
return deleted.length > 0;
}Because the service imports the connection (and therefore the pg driver), only
call it from server-only route files (.ts). A .ts loader/action never
ships to the browser, so your database code and credentials stay on the server.
The matching .tsx file imports only the types, which are erased at build
time.
// routes/index.ts — server loader + action
import { redirect } from "react-router";
import type {
AnyParams,
RouteActionArgs,
RouteLoaderArgs,
} from "@udibo/juniper";
import {
createMessage,
deleteMessage,
listMessages,
newMessageSchema,
} from "@/services/message.ts";
import type { GuestbookActionData, GuestbookLoaderData } from "./index.tsx";
export async function loader(
_args: RouteLoaderArgs<AnyParams, GuestbookLoaderData>,
): Promise<GuestbookLoaderData> {
return { messages: await listMessages() };
}
export async function action(
{ request }: RouteActionArgs<AnyParams, GuestbookActionData>,
): Promise<GuestbookActionData> {
const formData = await request.formData();
if (formData.get("intent") === "delete") {
const id = Number(formData.get("id"));
if (Number.isInteger(id)) await deleteMessage(id);
throw redirect("/");
}
const result = newMessageSchema.safeParse({
name: formData.get("name") ?? "",
body: formData.get("body") ?? "",
});
if (!result.success) {
const errors: NonNullable<GuestbookActionData["errors"]> = {};
for (const issue of result.error.issues) {
const field = issue.path[0];
if ((field === "name" || field === "body") && !errors[field]) {
errors[field] = issue.message;
}
}
return { errors };
}
await createMessage(result.data);
throw redirect("/");
}The component (.tsx) receives the data through props and imports only the
Message type from the service:
// routes/index.tsx
import { Form } from "react-router";
import type { AnyParams, RouteProps } from "@udibo/juniper";
import type { Message } from "@/services/message.ts";
export interface GuestbookLoaderData {
messages: Message[];
}
export interface GuestbookActionData {
errors?: { name?: string; body?: string };
}
export default function Guestbook(
{ loaderData, actionData }: RouteProps<
AnyParams,
GuestbookLoaderData,
GuestbookActionData
>,
) {
return (
<>
<Form method="post">
<input type="text" name="name" maxLength={100} required />
{actionData?.errors?.name && <span>{actionData.errors.name}</span>}
<textarea name="body" maxLength={1000} required />
{actionData?.errors?.body && <span>{actionData.errors.body}</span>}
<button type="submit">Sign guestbook</button>
</Form>
<ul>
{loaderData.messages.map((message) => (
<li key={message.id}>
<strong>{message.name}</strong>: {message.body}
</li>
))}
</ul>
</>
);
}Tests run against the app_test database, so start and migrate PostgreSQL
before running them:
deno task docker:start
deno task db:migrate
deno task testClose the connection pool when a test file finishes querying the database so the test runner doesn't report a leaked resource:
import { afterAll, describe, it } from "@std/testing/bdd";
import { closeDb } from "@/db/mod.ts";
describe("message service", () => {
afterAll(async () => {
await closeDb();
});
// ...tests that call the service
});These are common patterns for organizing data access code. They are not framework-specific features but recommended approaches for maintainable applications.
Encapsulate database operations in service classes or modules:
// services/post.ts
import { z } from "zod";
import { HttpError } from "@udibo/juniper";
export const PostSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(255),
content: z.string().min(1),
authorId: z.string().uuid(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export type Post = z.infer<typeof PostSchema>;
export type NewPost = Omit<Post, "id" | "createdAt" | "updatedAt">;
class PostService {
private kv: Deno.Kv | undefined;
private async getKv(): Promise<Deno.Kv> {
if (!this.kv) {
this.kv = await Deno.openKv();
}
return this.kv;
}
async get(id: string): Promise<Post> {
const kv = await this.getKv();
const entry = await kv.get<Post>(["posts", "id", id]);
if (!entry.value) {
throw new HttpError(404, "Post not found");
}
return entry.value;
}
async list(authorId?: string): Promise<Post[]> {
const kv = await this.getKv();
const prefix = authorId ? ["posts", "authorId", authorId] : ["posts", "id"];
const entries = kv.list<Post>({ prefix });
const posts: Post[] = [];
for await (const entry of entries) {
posts.push(entry.value);
}
return posts;
}
async create(data: NewPost): Promise<Post> {
const kv = await this.getKv();
const post: Post = {
...data,
id: crypto.randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
};
await kv.atomic()
.set(["posts", "id", post.id], post)
.set(["posts", "authorId", post.authorId, post.id], post)
.commit();
return post;
}
async delete(id: string): Promise<void> {
const kv = await this.getKv();
const post = await this.get(id);
await kv.atomic()
.delete(["posts", "id", id])
.delete(["posts", "authorId", post.authorId, id])
.commit();
}
}
export const postService = new PostService();Use the service in your routes:
// routes/blog/[id].ts
import type { RouteLoaderArgs } from "@udibo/juniper";
import { postService } from "@/services/post.ts";
export async function loader({ params }: RouteLoaderArgs) {
const post = await postService.get(params.id);
return { post };
}For larger applications, separate the data access logic from business logic:
// repositories/user.ts
export interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
create(user: NewUser): Promise<User>;
update(user: User): Promise<User>;
delete(id: string): Promise<void>;
}
export class KvUserRepository implements UserRepository {
constructor(private kv: Deno.Kv) {}
async findById(id: string): Promise<User | null> {
const entry = await this.kv.get<User>(["users", "id", id]);
return entry.value;
}
async findByEmail(email: string): Promise<User | null> {
const entry = await this.kv.get<User>(["users", "email", email]);
return entry.value;
}
// ... other methods
}// services/user.ts
import { UserRepository } from "@/repositories/user.ts";
import { HttpError } from "@udibo/juniper";
export class UserService {
constructor(private repo: UserRepository) {}
async getUser(id: string): Promise<User> {
const user = await this.repo.findById(id);
if (!user) {
throw new HttpError(404, "User not found");
}
return user;
}
async registerUser(data: NewUser): Promise<User> {
const existing = await this.repo.findByEmail(data.email);
if (existing) {
throw new HttpError(400, "Email already registered");
}
return this.repo.create(data);
}
}This pattern makes it easier to:
- Switch between different database implementations
- Mock the repository for testing
- Keep business logic separate from data access
Use Zod schemas for runtime validation:
import { z } from "zod";
import { HttpError } from "@udibo/juniper";
const CreatePostSchema = z.object({
title: z.string().min(1, "Title is required").max(255),
content: z.string().min(1, "Content is required"),
});
export async function action({ request }: RouteActionArgs) {
const formData = await request.formData();
const data = Object.fromEntries(formData);
const result = CreatePostSchema.safeParse(data);
if (!result.success) {
return {
errors: result.error.issues.map((i) => i.message),
};
}
const post = await postService.create({
...result.data,
authorId: currentUser.id,
});
return redirect(`/blog/${post.id}`);
}Next: Testing - Testing utilities and patterns
Related topics:
- Forms - Form handling with client and server actions
- Error Handling - Error boundaries and HttpError