I’ve been thinking about building APIs differently lately. You know that feeling when you’re tired of the same old setup? The endless npm install, the configuration files, the security headaches? That’s where I found myself. Then I discovered a different way. Let me show you what happens when you combine Deno, Oak, and Deno KV. It’s not just another tutorial—it’s a complete shift in how we build backends.
Think about this: what if your database was built into your runtime? No separate server to manage, no connection strings to leak. That’s Deno KV. And what if your framework was designed from the ground up for TypeScript? That’s Oak. This combination creates something special.
Let’s start with why this matters now. The web moves fast. Users expect speed, security, and reliability. Traditional setups can feel heavy. They require stitching together many packages. Deno offers a cohesive experience. It’s a single binary with batteries included. This changes everything.
First, we need to set up. Create a new directory and initialize it.
deno init my-api
cd my-api
Look at the generated deno.jsonc file. This is your project’s heart. It manages dependencies and tasks. Notice something different? No node_modules folder. Deno uses URL imports. This means your dependencies are clearly declared.
{
"tasks": {
"dev": "deno run --watch --allow-net --allow-env --unstable-kv main.ts",
"start": "deno run --allow-net --allow-env --unstable-kv main.ts"
},
"imports": {
"oak": "https://deno.land/x/[email protected]/mod.ts"
}
}
See those --allow flags? That’s Deno’s security model. By default, scripts can’t access the network or environment. You must explicitly permit it. This prevents surprises. How many times have you installed a package only to find it making network calls you didn’t expect?
Now, let’s build our server. Create a main.ts file.
import { Application, Router } from "oak";
const app = new Application();
const router = new Router();
router.get("/", (ctx) => {
ctx.response.body = { message: "API is running" };
});
app.use(router.routes());
app.use(router.allowedMethods());
console.log("Server started on http://localhost:8000");
await app.listen({ port: 8000 });
Run it with deno task dev. That’s it. You have a running server. No npm install, no package.json. Just clean, straightforward code.
But where’s the database? Here’s where it gets interesting. Deno KV is built in. Let’s connect to it.
// In a real app, you'd manage this connection properly
const kv = await Deno.openKv();
// Store a user
await kv.set(["users", "user123"], {
id: "user123",
name: "Alex Johnson",
email: "[email protected]",
createdAt: new Date()
});
// Retrieve that user
const user = await kv.get(["users", "user123"]);
console.log(user.value);
Notice the pattern? Keys are arrays. This creates a natural hierarchy. ["users", "user123"] is like a path. It’s intuitive. You can list all users with a prefix query.
// Get all users
const users = [];
for await (const entry of kv.list({ prefix: ["users"] })) {
users.push(entry.value);
}
This is powerful. But what about relationships? Let’s say we have products and orders. We can structure our data thoughtfully.
// Store a product
await kv.set(["products", "prod456"], {
id: "prod456",
name: "Coffee Mug",
price: 1999,
category: "kitchen"
});
// Store an order that references the product
await kv.set(["orders", "order789"], {
id: "order789",
userId: "user123",
items: [
{ productId: "prod456", quantity: 2 }
],
total: 3998,
status: "pending"
});
Now, here’s a question: how do we ensure data consistency? What if our order creation fails halfway? Deno KV supports atomic operations. This means multiple changes succeed or fail together.
const result = await kv.atomic()
.check({ key: ["users", "user123"], versionstamp: userVersionstamp })
.set(["orders", "order789"], orderData)
.set(["users", "user123", "orders", "order789"], { status: "pending" })
.commit();
This is ACID compliance. It’s built in. You don’t need to configure a transaction. Just chain your operations and call commit.
Let’s build a proper API endpoint. We’ll create a user registration route.
router.post("/users", async (ctx) => {
try {
const body = await ctx.request.body().value;
// Basic validation
if (!body.email || !body.password) {
ctx.response.status = 400;
ctx.response.body = { error: "Email and password required" };
return;
}
const userId = crypto.randomUUID();
const userKey = ["users", userId];
// Check if user exists
const existing = await kv.get(["users_by_email", body.email]);
if (existing.value) {
ctx.response.status = 409;
ctx.response.body = { error: "User already exists" };
return;
}
// Store user in a transaction
await kv.atomic()
.set(userKey, {
id: userId,
email: body.email,
// In reality, hash this password!
passwordHash: body.password,
createdAt: new Date()
})
.set(["users_by_email", body.email], userId)
.commit();
ctx.response.status = 201;
ctx.response.body = { id: userId, email: body.email };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { error: "Registration failed" };
}
});
See what we did? We created two entries. One for the user data, another for email lookup. This is a common pattern. It allows fast email-based retrieval. The atomic operation ensures both write or neither write.
But wait—where’s the authentication? Let’s add login.
import { create, verify } from "https://deno.land/x/[email protected]/mod.ts";
const JWT_SECRET = Deno.env.get("JWT_SECRET") || "dev-secret";
router.post("/login", async (ctx) => {
const { email, password } = await ctx.request.body().value;
// Get user ID from email index
const userIdEntry = await kv.get(["users_by_email", email]);
if (!userIdEntry.value) {
ctx.response.status = 401;
return;
}
// Get user data
const user = await kv.get(["users", userIdEntry.value]);
if (!user.value) {
ctx.response.status = 401;
return;
}
// In reality, use proper password hashing!
if (user.value.passwordHash !== password) {
ctx.response.status = 401;
return;
}
// Create JWT
const payload = { userId: user.value.id, email: user.value.email };
const jwt = await create({ alg: "HS256", typ: "JWT" }, payload, JWT_SECRET);
// Store session in KV
await kv.set(["sessions", jwt], {
userId: user.value.id,
createdAt: new Date(),
lastActive: new Date()
});
ctx.response.body = { token: jwt, user: { id: user.value.id, email: user.value.email } };
});
Now we need middleware to protect routes. This checks the JWT on incoming requests.
async function authMiddleware(ctx: any, next: any) {
const authHeader = ctx.request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
ctx.response.status = 401;
return;
}
const token = authHeader.substring(7);
try {
// Verify the token
const payload = await verify(token, JWT_SECRET, "HS256");
// Check if session exists in KV
const session = await kv.get(["sessions", token]);
if (!session.value) {
ctx.response.status = 401;
return;
}
// Update last active time
await kv.set(["sessions", token], {
...session.value,
lastActive: new Date()
});
// Attach user to context
ctx.state.user = payload;
await next();
} catch {
ctx.response.status = 401;
}
}
// Use it on a route
router.get("/profile", authMiddleware, async (ctx) => {
const user = await kv.get(["users", ctx.state.user.userId]);
ctx.response.body = {
id: user.value.id,
email: user.value.email,
createdAt: user.value.createdAt
};
});
This pattern is clean. The middleware does the heavy lifting. Routes stay simple. But what about validation? We should check incoming data. Let’s use Zod.
import { z } from "https://deno.land/x/[email protected]/mod.ts";
const UserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().optional()
});
router.post("/users", async (ctx) => {
const rawData = await ctx.request.body().value;
const result = UserSchema.safeParse(rawData);
if (!result.success) {
ctx.response.status = 400;
ctx.response.body = { errors: result.error.errors };
return;
}
// Proceed with valid data
const userData = result.data;
// ... rest of registration logic
});
Validation becomes declarative. The schema defines what’s acceptable. Zod gives detailed error messages. This is better than manual checks.
Now, consider performance. Deno KV has a built-in cache. Frequently accessed data stays fast. But we can optimize further. What if we cache API responses?
const responseCache = new Map();
async function cachedGet(ctx: any, next: any, cacheKey: string, ttl: number) {
const cached = responseCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < ttl) {
ctx.response.body = cached.data;
return;
}
await next();
if (ctx.response.status === 200) {
responseCache.set(cacheKey, {
data: ctx.response.body,
timestamp: Date.now()
});
}
}
router.get("/products", async (ctx, next) => {
await cachedGet(ctx, next, "all_products", 30000); // 30 second cache
// Your product fetching logic here
const products = [];
for await (const entry of kv.list({ prefix: ["products"] })) {
products.push(entry.value);
}
ctx.response.body = products;
});
This is a simple in-memory cache. For production, you might use Deno KV itself for caching. The point is: caching is easy to add.
What about errors? We need consistent error handling.
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
console.error("API Error:", err);
ctx.response.status = err.status || 500;
ctx.response.body = {
error: err.message || "Internal server error",
requestId: crypto.randomUUID() // For tracking
};
}
});
This catches unhandled errors. It gives clients a clean response. It also logs the error for debugging.
Let’s talk about deployment. Deno Deploy makes this trivial.
# Install deployctl
deno install --allow-read --allow-write --allow-env --allow-net --allow-run --no-check -r -f https://deno.land/x/deploy/deployctl.ts
# Deploy your project
deployctl deploy --project=my-api ./main.ts
That’s it. Your API is live globally. Deno Deploy runs on the edge. This means low latency worldwide. The same code runs everywhere.
But here’s something to think about: when should you not use this stack? If you need complex SQL queries, consider a traditional database. If you rely on specific npm packages without Deno support, check compatibility first. For most APIs though, this stack is excellent.
The beauty is in the simplicity. One runtime. One database. One deployment story. Everything works together. You spend less time configuring and more time building.
Remember the initial question? What if building APIs could be simpler? This stack answers that. It removes friction. It provides sensible defaults. It lets you focus on your application logic.
I encourage you to try it. Start a small project. See how it feels. You might find, as I did, that it changes your approach to backend development.
What has your experience been with modern backend stacks? Have you tried Deno in production? I’d love to hear your thoughts in the comments. If this guide helped you, please share it with other developers who might be looking for a cleaner way to build APIs.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva