/ astro-integrations / How to Use Turso with Astro: Complete Guide
astro-integrations 10 min read

How to Use Turso with Astro: Complete Guide

Step-by-step guide to integrating Turso with your Astro website.

How to Use Turso with Astro: Complete Guide

Turso is an edge-native database built on libSQL, which is an open-source fork of SQLite. Instead of running a centralized database server, Turso replicates your data to edge locations close to your users. The result is read latencies measured in single-digit milliseconds, no matter where your visitors are.

For Astro projects that need a database but want to keep things lightweight, Turso is a compelling option. You get the simplicity of SQLite with the scalability of a distributed database. No connection pooling headaches, no complex cluster management.

Prerequisites

  • Node.js 22.12.0 or newer. Astro 6 sets this floor in its engines field, so older Node versions will fail to install or build.
  • An Astro project (npm create astro@latest). This guide was checked against Astro 6.4.2.
  • A Turso account. The current Free plan gives you 100 databases, 5GB of storage, 500 million row reads per month, and 10 million row writes per month.
  • The Turso CLI installed. On macOS use brew install tursodatabase/tap/turso. On Linux (or macOS without Homebrew) use the install script:
curl -sSfL https://get.tur.so/install.sh | bash

After installing, open a new shell and run turso to confirm the binary is on your PATH.

Installation

Install the Turso client library. Pin the current version so your lockfile is reproducible:

npm install @libsql/client@0.17.3

The package ships several entry points (., ./web, ./node, ./http, ./ws, ./sqlite3). Both the official Turso guide and the Astro docs import from @libsql/client/web, which is the variant designed for serverless and edge runtimes and the one used throughout this guide.

Create a database using the Turso CLI:

turso db create my-astro-blog
turso db show my-astro-blog --url
turso db tokens create my-astro-blog

Save the URL and token. You will need them in the next step.

Configuration

Add your Turso credentials to .env:

TURSO_DATABASE_URL=libsql://your-database-name-your-org.turso.io
TURSO_AUTH_TOKEN=your_auth_token_here

A note on the variable names. Do not prefix these with PUBLIC_. The Astro docs warn explicitly that the PUBLIC_ prefix exposes a value to the client bundle, which would leak your auth token to every visitor.

Create a database client helper. The official guides place this at src/turso.ts and import from the /web entry point:

// src/turso.ts
import { createClient } from "@libsql/client/web";

export const turso = createClient({
  url: import.meta.env.TURSO_DATABASE_URL,
  authToken: import.meta.env.TURSO_AUTH_TOKEN,
});

Set up your schema. You can run migrations through the Turso CLI or programmatically:

turso db shell my-astro-blog
CREATE TABLE posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  slug TEXT NOT NULL UNIQUE,
  content TEXT NOT NULL,
  published_at TEXT DEFAULT (datetime('now')),
  draft INTEGER DEFAULT 0
);

CREATE TABLE comments (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  post_id INTEGER REFERENCES posts(id),
  author TEXT NOT NULL,
  body TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now'))
);

Basic Usage

Fetch posts from Turso in your Astro pages:

---
// src/pages/blog/index.astro
import { turso } from "../../turso";
import BaseLayout from "../../layouts/BaseLayout.astro";

const result = await turso.execute(
  "SELECT id, title, slug, published_at FROM posts WHERE draft = 0 ORDER BY published_at DESC"
);

const posts = result.rows;
---

<BaseLayout title="Blog">
  <h1>Blog</h1>
  <ul>
    {posts.map((post) => (
      <li>
        <a href={`/blog/${post.slug}`}>
          <h2>{post.title}</h2>
          <time>{post.published_at}</time>
        </a>
      </li>
    ))}
  </ul>
</BaseLayout>

For individual post pages rendered on demand (see the rendering modes section below for why prerender = false matters):

---
// src/pages/blog/[slug].astro
export const prerender = false;

import { turso } from "../../turso";
import BaseLayout from "../../layouts/BaseLayout.astro";

const { slug } = Astro.params;

const postResult = await turso.execute({
  sql: "SELECT * FROM posts WHERE slug = ? AND draft = 0",
  args: [slug],
});

if (postResult.rows.length === 0) {
  return Astro.redirect("/404");
}

const post = postResult.rows[0];

const commentsResult = await turso.execute({
  sql: "SELECT * FROM comments WHERE post_id = ? ORDER BY created_at DESC",
  args: [post.id],
});
---

<BaseLayout title={post.title}>
  <article>
    <h1>{post.title}</h1>
    <div set:html={post.content} />
  </article>
  <section>
    <h2>Comments</h2>
    {commentsResult.rows.map((comment) => (
      <div>
        <strong>{comment.author}</strong>
        <p>{comment.body}</p>
      </div>
    ))}
  </section>
</BaseLayout>

Rendering Modes in Astro 6

This is the part that trips people up, and it changed across Astro versions. In Astro 6 the default output is static. Your entire site is prerendered to HTML at build time unless you opt a route into on-demand rendering. The old output: 'hybrid' value was removed in Astro 5, so any guide that still tells you to set it is out of date. Static is now the baseline and you opt individual routes into server rendering instead.

There are two ways to query Turso, and they map to two different needs:

  1. At build time (static). If your post list rarely changes, leave the page static. The Turso query in src/pages/blog/index.astro runs once during astro build and the result is baked into the HTML. You do not need an adapter for this.

  2. On demand (server rendered). If a page must reflect data that changes between deploys, for example a comment count or a freshly published draft, opt that route into on-demand rendering with export const prerender = false. On-demand rendering requires an adapter to be installed.

Install the Node adapter with the official command, which writes the config and installs @astrojs/node for you (version 10.1.2 at time of writing, which peers Astro ^6.3.0):

npx astro add node

That produces an astro.config.mjs like this:

import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  adapter: node({
    mode: 'standalone',
  }),
});

With mode: 'standalone' the build produces a self-contained server you can start directly. Use mode: 'middleware' instead if you are mounting Astro inside an existing Express or Fastify app. Leaving output unset keeps the site static-by-default and lets your prerender = false pages render on demand. Only set output: 'server' if you want every page server rendered by default, in which case you flip the static pages back with export const prerender = true.

Production Tips

  1. Use embedded replicas for fast local reads. Turso supports embedded replicas that sync to a local SQLite file. You point the client at a file: URL and give it a syncUrl, and reads hit the local file while writes sync back to Turso. The SDK reference shows the shape:

    import { createClient } from "@libsql/client";
    
    const client = createClient({
      url: "file:local.db",
      syncUrl: "libsql://your-database-name-your-org.turso.io",
      authToken: import.meta.env.TURSO_AUTH_TOKEN,
    });
    

    Add a syncInterval (in seconds) for periodic background syncing, or call await client.sync() manually. Note that embedded replicas use the Node entry point, not @libsql/client/web.

  • Use parameterized queries. Always pass user input through args instead of string interpolation. This prevents SQL injection and is the documented way to use the libSQL client. libSQL accepts the same named placeholder characters as SQLite (:, @, and $) in addition to positional ?.

  • Batch related queries. Turso supports batching multiple statements in a single round trip. turso.batch() takes an array of statements plus a transaction mode, for example await turso.batch([...], "write"). The whole batch runs as a transaction, so it rolls back together if any statement fails.

  • Set up database groups for multi-region. If your audience is global, create a database group with replicas in multiple regions. Turso routes queries to the nearest replica automatically.

  • Monitor your usage. The current Free plan includes 100 databases, 5GB of total storage, 500 million row reads per month, and 10 million row writes per month. Read-heavy sites tend to sit comfortably inside the free tier. Write-heavy applications should watch the monthly row-write count, since that is the limit you are most likely to hit first.

  • Alternatives to Consider

    • Supabase if you want a full PostgreSQL database with authentication, storage, and real-time subscriptions included.
    • PlanetScale if you prefer MySQL and need branching for schema changes in a team environment.
    • Neon if you want serverless PostgreSQL with a generous free tier and automatic scaling.

    Common Errors and Fixes

    Your token shows up in the browser. If you prefix the env vars with PUBLIC_, Astro inlines them into the client bundle. The Astro docs call this out directly. Use TURSO_DATABASE_URL and TURSO_AUTH_TOKEN with no prefix, and read them with import.meta.env only inside server-side code (the frontmatter of an .astro file, an API route, or src/turso.ts).

    A page that queries Turso throws at build time or returns stale data. A static page runs its query once during astro build. If you need the data fresh on every request, that route must be on demand. Add export const prerender = false to the page, and make sure an adapter is installed. Without an adapter, Astro 6 has nowhere to run the server code.

    output: 'hybrid' is not a valid configuration value. That option was removed in Astro 5. In Astro 6 the default is static, and you opt routes into server rendering with prerender = false (or set output: 'server' and opt routes back out with prerender = true). Delete any leftover output: 'hybrid' line.

    Build fails immediately with an engines or syntax error. Astro 6 requires Node 22.12.0 or newer per its engines field. Check node -v and upgrade if you are on 18 or 20.

    Server islands need an adapter too. If you move a Turso-backed component to server:defer for a server island, that also requires an adapter to be installed, the same as any on-demand route.

    Wrong client entry point for your runtime. Use @libsql/client/web for the standard remote-database setup shown here. Switch to the Node entry point (@libsql/client) when you use embedded replicas with a file: URL, because the web build cannot open a local SQLite file.

    Official Docs and Examples

    Wrapping Up

    Turso brings the simplicity of SQLite to the edge. For Astro projects that need a database, whether for comments, user data, or dynamic content, Turso provides a lightweight solution with excellent performance. The free tier is generous, the client library is straightforward, and the edge replication means your data is always close to your users.

    Sources

    All versions and facts below were checked on 2026-05-29.