
How to connect Astro to a database?
To connect Astro to a database, install a database client in your project, create the connection once in a module such as src/db/index.ts, and import it into the server code that needs it: on-demand pages, API endpoints, actions or middleware. Astro doesn't need a special integration for this, because its server code is ordinary Node or edge JavaScript. Since Astro 7 removed the @astrojs/db package, the main options are Node's built-in node:sqlite module for a local SQLite file, Drizzle ORM on top of any supported database, or a provider's own client for services such as Turso, Neon or PlanetScale. Keep connection strings in server-only environment variables and always use parameterised queries.
If you've followed older tutorials, you may remember astro:db, db/config.ts and astro db push. That package is gone in Astro 7 and is no longer maintained, so new projects should use one of the general-purpose approaches below. In this article you'll learn where database code can run in Astro, how to build a small guestbook with node:sqlite, how to set up Drizzle ORM with a typed schema and migrations, how to store credentials safely with astro:env, how to query at build time for static pages, and how to choose a hosted database for serverless platforms.
Where Database Code Runs
Database queries need credentials and a network connection, so they must never run in the browser. In Astro, that means:
- On-demand pages: frontmatter in pages with
export const prerender = falseruns on every request. - API endpoints:
GET,POSTand other handlers insrc/pages. - Actions: handlers in
src/actions, which is often the cleanest place for writes. See Astro Actions. - Middleware: for things like loading the current user on each request.
- Prerendered pages and content loaders: these run once at build time, which is perfect for content that changes rarely.
Code in <script> tags and client-hydrated framework components runs in the browser, so it has to call one of the server options above rather than the database directly.
Per-request queries need an adapter. If you haven't added one, start with what Astro adapters are and which one to choose. Every example here was built and run with Astro 7.4.2 and @astrojs/node.
Option 1: node:sqlite with No Dependencies
Node.js ships a synchronous SQLite driver in the node:sqlite module, available from Node 22.5. Astro 7 requires a newer Node than that, so if you deploy to a Node server with a persistent disk, you can use SQLite without installing anything.
Create the Connection Module
// src/lib/sqlite.ts
import { DatabaseSync } from 'node:sqlite';
export const db = new DatabaseSync(process.env.DATABASE_PATH ?? 'data.sqlite');
db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
export type Message = { id: number; name: string; body: string; created_at: string };
const listStmt = db.prepare(
'SELECT id, name, body, created_at FROM messages ORDER BY id DESC LIMIT ?'
);
const insertStmt = db.prepare('INSERT INTO messages (name, body) VALUES (?, ?)');
export function listMessages(limit = 20) {
return listStmt.all(limit) as Message[];
}
export function addMessage(name: string, body: string) {
const result = insertStmt.run(name, body);
return Number(result.lastInsertRowid);
}
A few things are worth noticing:
- The module is evaluated once per server process, so the connection and prepared statements are reused across requests.
- Prepared statements with
?placeholders keep user input out of the SQL text. That's what protects you from SQL injection. process.env.DATABASE_PATHis read at runtime, so you can point production at a file on a mounted volume.- Types: if
astro checkreportsCannot find name 'node:sqlite', install@types/nodeas a dev dependency.
Read and Write from a Page
---
// src/pages/guestbook.astro
export const prerender = false;
import { listMessages, addMessage } from '../lib/sqlite';
let error = '';
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData();
const name = String(form.get('name') ?? '').trim();
const body = String(form.get('body') ?? '').trim();
if (name && body) {
addMessage(name, body);
return Astro.redirect('/guestbook', 303);
}
error = 'Both fields are required.';
}
const messages = listMessages();
---
<html lang="en">
<body>
<h1>Guestbook</h1>
{error && <p role="alert">{error}</p>}
<form method="POST">
<input name="name" placeholder="Your name" required />
<textarea name="body" required></textarea>
<button>Sign</button>
</form>
<ul>
{messages.map((m) => <li><strong>{m.name}</strong>: {m.body}</li>)}
</ul>
</body>
</html>
The 303 redirect after a successful insert is the POST/Redirect/GET pattern: refreshing the page afterwards doesn't submit the form again. The post on handling form submissions in Astro covers the pattern in more depth.
To test it, two entries were posted to the running build, the second one a classic injection attempt:
curl -X POST http://localhost:4321/guestbook -d name=Ada -d body=Hello
curl -X POST http://localhost:4321/guestbook -d "name=Grace&body=Robert'); DROP TABLE messages;--"
Both returned 303, and the page listed both messages:
<ul><li><strong>Grace</strong>: Robert'); DROP TABLE messages;--</li><li><strong>Ada</strong>: Hello</li></ul>
The table survived because the input was bound as a parameter, and Astro escaped the apostrophe when rendering. A submission missing a field rendered <p role="alert">Both fields are required.</p> instead.
Expose the Data as JSON
The same module works in an endpoint:
// src/pages/api/messages.ts
import type { APIRoute } from 'astro';
import { listMessages } from '../../lib/sqlite';
export const prerender = false;
export const GET: APIRoute = ({ url }) => {
const limit = Math.min(Number(url.searchParams.get('limit')) || 20, 100);
return Response.json(listMessages(limit));
};
[{"id":2,"name":"Grace","body":"Robert'); DROP TABLE messages;--","created_at":"2026-10-24 09:43:28"}]
Capping limit at 100 stops anyone requesting the whole table in one go.
When node:sqlite Isn't Enough
node:sqlite is ideal for a single long-running Node server with a writable disk, such as a VPS or a container with a volume (see deploying Astro with Docker and Node.js). It's a poor fit for serverless platforms like Vercel and Netlify, where each function instance has its own temporary filesystem and data written by one request may vanish. For those, use a hosted database.
Option 2: Drizzle ORM
Drizzle is a TypeScript ORM that gives you a typed schema, a query builder that stays close to SQL, and a migration tool. It supports PostgreSQL, MySQL and SQLite, including hosted services such as Turso, Neon, PlanetScale and Supabase. This example uses Turso's libsql client, which can also open a local SQLite file, so the same code works in development and production.
Install
npm install drizzle-orm @libsql/client
npm install -D drizzle-kit
Define the Schema
// src/db/schema.ts
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const posts = sqliteTable('posts', {
id: int().primaryKey({ autoIncrement: true }),
slug: text().notNull().unique(),
title: text().notNull(),
likes: int().notNull().default(0),
});
Store Credentials with astro:env
Declare the connection settings in your Astro config so they're validated and only ever available on the server:
// astro.config.mjs
import { defineConfig, envField } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
adapter: node({ mode: 'standalone' }),
env: {
schema: {
DATABASE_URL: envField.string({ context: 'server', access: 'secret' }),
DATABASE_AUTH_TOKEN: envField.string({ context: 'server', access: 'secret', optional: true }),
},
},
});
access: 'secret' means the values are read at runtime and are never inlined into the build or exposed to client code. If one is missing when the server needs it, Astro throws a clear error rather than letting the client fail with a vague connection message. Starting the built server without DATABASE_URL logged:
[ERROR] EnvInvalidVariables: The following environment variables defined in `env.schema` are invalid:
- DATABASE_URL is missing
The post on type-safe environment variables with astro:env explains the options in detail. For local development, put the values in .env:
# .env
DATABASE_URL=file:local.db
In production you'd set DATABASE_URL to your Turso database URL, such as libsql://your-db.turso.io, and DATABASE_AUTH_TOKEN to its token, in your host's environment settings. Note that the standalone Node server doesn't read .env on its own, so pass real environment variables when you run the built app.
Create the Client
// src/db/index.ts
import { drizzle } from 'drizzle-orm/libsql';
import { DATABASE_URL, DATABASE_AUTH_TOKEN } from 'astro:env/server';
import * as schema from './schema';
export const db = drizzle({
connection: { url: DATABASE_URL, authToken: DATABASE_AUTH_TOKEN },
schema,
});
Push the Schema
Drizzle Kit needs its own config file. It runs outside Astro, so it reads process.env directly:
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'turso',
dbCredentials: {
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_AUTH_TOKEN,
},
});
DATABASE_URL=file:local.db npx drizzle-kit push
[✓] Pulling schema from database...
[✓] Changes applied
push applies the schema directly, which is perfect while you're iterating locally. For production, use npx drizzle-kit generate to write SQL migration files into ./drizzle, commit them, and apply them with npx drizzle-kit migrate as part of your deploy.
Query from a Page
---
// src/pages/posts.astro
export const prerender = false;
import { desc } from 'drizzle-orm';
import { db } from '../db';
import { posts } from '../db/schema';
const rows = await db.select().from(posts).orderBy(desc(posts.likes));
---
<ul>
{rows.map((post) => <li><a href={`/blog/${post.slug}`}>{post.title}</a> ({post.likes} likes)</li>)}
</ul>
rows is fully typed from the schema, so post.likes is a number and a typo like post.like is a compile error. With two seeded rows, the page rendered:
<ul><li><a href="/blog/astro-sessions">How to use sessions in Astro?</a> (5 likes)</li><li><a href="/blog/astro-actions">What are Astro Actions?</a> (3 likes)</li></ul>
Write from an Action
Writes fit naturally in actions, where the input is validated before it reaches the database:
// src/actions/index.ts
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro/zod';
import { eq, sql } from 'drizzle-orm';
import { db } from '../db';
import { posts } from '../db/schema';
export const server = {
likePost: defineAction({
input: z.object({ slug: z.string() }),
handler: async ({ slug }) => {
const [updated] = await db
.update(posts)
.set({ likes: sql`${posts.likes} + 1` })
.where(eq(posts.slug, slug))
.returning({ likes: posts.likes });
if (!updated) {
throw new ActionError({ code: 'NOT_FOUND', message: 'No such post.' });
}
return updated.likes;
},
}),
};
Incrementing with sql`${posts.likes} + 1` does the update in a single statement, so two people liking a post at the same moment can't overwrite each other's count. Calling the action returned the new total, and an unknown slug returned a typed error:
[4]
{"type":"AstroActionError","code":"NOT_FOUND","status":404,"message":"No such post."}
Option 3: A Hosted Database Client
You don't need an ORM. Every major hosted database has a JavaScript client you can import directly in the same places: @libsql/client for Turso, @neondatabase/serverless for Neon, @planetscale/database for PlanetScale and @supabase/supabase-js for Supabase. Follow the provider's documentation for the connection code, and apply the same rules: create the client in one module, keep credentials in astro:env secrets, and use parameterised queries or the client's query builder.
HTTP-based clients such as Neon's serverless driver and PlanetScale's are designed for serverless and edge platforms, where opening a traditional TCP connection on every request is slow or impossible. If you deploy to Cloudflare, Vercel or Netlify, prefer one of these, or a connection pooler provided by your database host.
Querying at Build Time
Not every page needs a live query. For data that changes rarely, query the database in a prerendered page and let Astro bake the result into static HTML:
---
// src/pages/blog/[slug].astro
import { db } from '../../db';
import { posts } from '../../db/schema';
export async function getStaticPaths() {
const rows = await db.select().from(posts);
return rows.map((post) => ({ params: { slug: post.slug }, props: { post } }));
}
const { post } = Astro.props;
---
<h1>{post.title}</h1>
This runs once during astro build, so the database only needs to be reachable from your build environment. For larger data sets, a custom content loader can pull rows into a content collection with schema validation, as described in loading content from a remote API with an Astro loader.
Choosing an Approach
| Approach | Best for | Watch out for |
|---|---|---|
node:sqlite | Single Node server, prototypes, small sites | Needs a persistent disk; not for serverless |
| Drizzle + SQLite/Turso | Typed queries, local file in dev, hosted in prod | Extra tooling for migrations |
| Drizzle + Postgres (Neon, Supabase) | Relational data at scale | Use HTTP drivers or pooling on serverless |
| Provider client only | Simple needs, provider features | Less type safety without a schema |
| Build-time queries | Content that rarely changes | Changes need a rebuild |
FAQ: Databases in Astro
The @astrojs/db package was removed in Astro 7 and is no longer maintained. Remove it from your dependencies and use node:sqlite, Drizzle ORM or another database client directly instead.
Yes, in the frontmatter of an on-demand page or a server-rendered component, because that code runs on the server. Never query from client-side scripts or hydrated framework components, which run in the browser.
Not reliably. Serverless functions don't share a persistent filesystem, so data written by one request may be lost. Use a hosted database such as Turso, Neon or PlanetScale on serverless platforms.
Declare it in env.schema with envField.string, context server and access secret, and import it from astro:env/server. Secrets are read at runtime, never inlined into the build and never exposed to the client.
Use push for quick local iteration. For production, generate migration files with drizzle-kit generate, commit them, and apply them with drizzle-kit migrate so every change is reviewed and repeatable.
Never build SQL by concatenating user input. Use prepared statements with placeholders in node:sqlite, or a query builder such as Drizzle, which sends values as bound parameters.
Conclusion
Connecting Astro to a database doesn't need a dedicated integration. Create the client once in a module, import it into on-demand pages, endpoints, actions or build-time code, and keep credentials in server-only astro:env secrets. With @astrojs/db gone in Astro 7, node:sqlite covers simple single-server sites with zero dependencies, while Drizzle ORM gives you typed schemas and migrations on top of SQLite, Turso, Postgres or MySQL.
Choose the storage that matches where you deploy: a local SQLite file only makes sense with a persistent disk, while serverless platforms need a hosted database with an HTTP or pooled driver. Use parameterised queries, do writes in validated actions, and query at build time whenever the data can be static. That combination keeps your site fast, safe and easy to change.


