Building Fullstack Applications with AstroDB at the Edge
/ 9 min read
We’ve optimized our assets, adopted a modern framework, and deployed to a global edge network. Your app is fast—until a user needs to save a preference, load dynamic content, or submit a form. Suddenly, that request travels across oceans to a single database server, and the illusion of speed shatters. The truth is, an application isn’t truly “at the edge” if its data isn’t. This is the core challenge Astro DB tackles head-on.
It provides the integrated, performant data layer that transforms edge-hosted frontends into complete, edge-native applications. Let’s dive into how it works.
This blog breaks down how Astro DB eliminates the data-distance trade-off, simplifying the path to building resilient, low-latency applications for a worldwide audience.
Astro DB and its Edge Computing
Let’s first get familear with few terminologies before we dive into see its real implementations.
Edge Computing: Think of Edge Computing like a local neighborhood bakery—instead of driving to a massive factory in another state to get bread, we get it right down the street. By processing data closer to where we live, websites load in a blink, save energy, and keep our info more secure.
To make this happen, we use Edge Functions. These are like tiny, lightning-fast robots that jump into action only when we need them, running our code at those local “bakeries” without needing a giant, expensive server running 24/7.
Astro: It is the framework that brings this all together. It’s built for speed, focusing on sending only what’s necessary to our screen so our phone doesn’t break a sweat. But every great app needs a brain, and that’s where Astro DB comes in.
Astro DB: It is a built-in, “set it and forget it” database for our website. Usually, setting up a database feels like assembling furniture without instructions, but Astro DB makes it as easy as saving a file. It lives right inside our project, handling all the technical heavy lifting behind the scenes. We get the power of a professional SQL database with none of the headache, allowing us to focus on building our site while Astro keeps everything running smoothly at the edge.
Implementing Astro DB
We’ll walk through a simple example of building a full-stack application using Astro DB. Imagine we want to create a simple CRUD application where todos are stored and retrieved from the database.
We will be using bun as our runtime environment. Bun is often considered better than Node.js because it is significantly faster and more integrated out of the box. It uses a highly optimized runtime written in Zig with JavaScriptCore, resulting in much faster startup times, installs, and script execution.
Bun combines what normally requires multiple tools in Node.js—package manager, bundler, test runner, and runtime—into a single, cohesive system, reducing configuration and dependency overhead.
We’ll initialize an Astro project and install Astro DB.
bun create astro@latestcd my-astro-appbun astro add dbDuring bun astro add db, we’ll be prompted to choose a database provider (e.g., Cloudflare D1 or Turso). For this example, let’s assume we’re using Cloudflare D1, which is commonly deployed to the edge via Cloudflare Workers.
Next, define our database schema in db/config.ts:
import { column, defineDb, defineTable } from 'astro:db';
const todos = defineTable({ columns: { id: column.number({ primaryKey: true }), title: column.text(), description: column.text({ optional: true }), completed: column.boolean({ default: false }), createdAt: column.date({ default: new Date() }), },});
export default defineDb({ tables: { todos },});Lets install and make sure our astro config file has three integrations we want to add in our astro project. Our config should look something like this
import { defineConfig } from 'astro/config';import react from '@astrojs/react';import db from '@astrojs/db';import tailwind from '@astrojs/tailwind';
export default defineConfig({ integrations: [react(), db(), tailwind()],});With the configuration above and schema defined, we can now run migrations to create our table with command
bunx astro db pushNow, let’s create an API endpoint (an Astro API route) to handle adding and fetching todos.
import type { APIRoute } from 'astro';import { db, eq } from 'astro:db';import { todos } from '@/db/config';
export const GET: APIRoute = async () => { const allTodos = await db.select().from(todos); return new Response(JSON.stringify(allTodos), { status: 200, headers: { 'Content-Type': 'application/json' }, });};
export const POST: APIRoute = async ({ request }) => { const data = await request.json(); const newTodo = await db.insert(todos).values({ title: data.title, description: data.description, completed: false, }).returning(); return new Response(JSON.stringify(newTodo), { status: 201, headers: { 'Content-Type': 'application/json' }, });};Astro DB includes a built-in Drizzle ORM client. There is no setup or manual configuration required to use the client. The Astro DB db client is automatically configured to communicate with our database (local or remote) when we run Astro.
It uses Drizzle the SQL-like way select() and insert() which is available in Drizzle API documentation for reference.
We’ll also need a dynamic API endpoint which takes in [id] and creates a Update and Delete operations. We can achieve it with file based routing available to us.
import type { APIRoute } from 'astro';import { db, eq } from 'astro:db';import { todos } from '@/db/config';
export const PUT: APIRoute = async ({ params, request }) => { const { id } = params; const data = await request.json(); const updated = await db .update(todos) .set(data) .where(eq(todos.id, parseInt(id!))) .returning(); return new Response(JSON.stringify(updated), { status: 200, headers: { 'Content-Type': 'application/json' }, });};
export const DELETE: APIRoute = async ({ params }) => { const { id } = params; await db.delete(todos).where(eq(todos.id, parseInt(id!))); return new Response(null, { status: 204 });};On the frontend, we can fetch all the todos and communicate with the API endpoints we have created. We could organise this code better adding a service layer for seperation of concern and use a data fetching libraries like tanstack but to keep things simple we will just add this to one single file.
import React, { useState, useEffect } from 'react';import type { Todo } from '@/types';
export default function TodoApp() { const [todos, setTodos] = useState<Todo[]>([]); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [loading, setLoading] = useState(true);
useEffect(() => { fetchTodos(); }, []);
const fetchTodos = async () => { try { const res = await fetch('/api/todos'); const data = await res.json(); setTodos(data); } catch (error) { console.error('Failed to fetch todos:', error); } finally { setLoading(false); } };
const addTodo = async (e: React.FormEvent) => { e.preventDefault(); if (!title.trim()) return;
try { const res = await fetch('/api/todos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, description }), }); const newTodo = await res.json(); setTodos([...todos, newTodo]); setTitle(''); setDescription(''); } catch (error) { console.error('Failed to add todo:', error); } };
const toggleTodo = async (id: number, completed: boolean) => { try { await fetch(`/api/todos/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ completed: !completed }), }); setTodos(todos.map(t => t.id === id ? { ...t, completed: !completed } : t)); } catch (error) { console.error('Failed to update todo:', error); } };
const deleteTodo = async (id: number) => { try { await fetch(`/api/todos/${id}`, { method: 'DELETE' }); setTodos(todos.filter(t => t.id !== id)); } catch (error) { console.error('Failed to delete todo:', error); } };
if (loading) return <div className="text-center py-8">Loading...</div>;
return ( <div className="max-w-2xl mx-auto p-6"> <h1 className="text-4xl font-bold mb-8 text-gray-900">My Todos</h1>
<form onSubmit={addTodo} className="mb-8 space-y-4"> <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Todo title" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" /> <textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" /> <button type="submit" className="w-full bg-blue-600 text-white font-semibold py-2 rounded-lg hover:bg-blue-700 transition" > Add Todo </button> </form>
<div className="space-y-3"> {todos.length === 0 ? ( <p className="text-gray-500 text-center py-8">No todos yet. Create one!</p> ) : ( todos.map(todo => ( <div key={todo.id} className="flex items-start gap-4 p-4 bg-white border border-gray-200 rounded-lg hover:shadow-md transition" > <input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo.id, todo.completed)} className="mt-1 w-5 h-5 cursor-pointer" /> <div className="flex-1"> <h3 className={`font-semibold ${todo.completed ? 'line-through text-gray-500' : 'text-gray-900'}`}> {todo.title} </h3> {todo.description && ( <p className="text-gray-600 text-sm mt-1">{todo.description}</p> )} </div> <button onClick={() => deleteTodo(todo.id)} className="text-red-600 hover:text-red-700 font-semibold transition" > Delete </button> </div> )) )} </div> </div> );}Finally, we can import our component into our astro file and mark it as client:load. The client:load
directive ensures that the component is only rendered on the client, which loads and hydrate the component JavaScript immediately on page load.
---import TodoApp from '@/components/TodoApp';---
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Fullstack Todo CRUD App</title></head><body class="bg-gray-50"> <TodoApp client:load /></body></html>When we deploy this application to an edge-first platform such as Cloudflare Pages, Astro’s API routes run as Cloudflare Workers. With Astro DB using Cloudflare D1 under the hood, database operations are performed directly at the edge or very close to the user’s location. This reduces unnecessary network travel, lowers response times, and results in a faster, more responsive sleek user experience.
Key Benefits
Edge-Optimized Performance: By taking advantage of edge-based databases like D1 and Turso, data is accessed near the end user, enabling quicker reads and writes.
Streamlined Full-Stack Development: Astro DB removes much of the complexity involved in database configuration and connectivity, letting developers concentrate on building features instead of managing infrastructure.
Unified Developer Experience: Database schemas, migrations, and query logic live directly inside the Astro project, creating a smooth and consistent workflow.
Built for Global Scale: Edge databases are designed for worldwide distribution and resilience, allowing applications to handle traffic growth reliably across regions.
Well-Suited for Dynamic and Data-Driven Apps: Projects that rely on dynamic content or basic data operations—such as blogs with comments, e-commerce catalogs, analytics dashboards, authentication systems, or real-time features—can greatly benefit from using Astro DB at the edge.
Conclusion
Astro DB is a strong choice for edge runtimes because it is designed to run where our users are. By integrating directly with edge platforms like Cloudflare Workers and edge-native databases such as Cloudflare D1 and Turso, it ensures database operations happen close to the request source, reducing network latency and improving response times.
By embedding a high-performance, developer-friendly database directly into the Astro ecosystem, it allows teams to create fast, reliable, and scalable applications without the usual complexity of managing distributed data systems. Astro DB turns globally distributed, low-latency applications from a difficult goal into a practical and approachable reality.
By placing data closer to users, it enables a smooth, efficient, and highly responsive full-stack development experience at the edge.
Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I’ll do my best to get back to you.
Thank you!