Next.js SQLite: Use SQLite in the App Router with better-sqlite3 (Tested)

Next.js can use SQLite as long as only server code opens the database file. In the App Router, that means code like route handlers and server components, never client components. In this article I’ll walk you through a small notes app on the App Router that reads and writes one SQLite file through better-sqlite3.

TLDR

  • Install better-sqlite3, and open the database once in a server-only module such as lib/db.ts.
  • Call it only from server code. Client components can’t open the file.
  • Next.js already treats better-sqlite3 as a server external package, so no extra config is needed.
  • Mark pages that read the database dynamic = 'force-dynamic', or they’re rendered once at build time.
  • SQLite needs a persistent disk. Vercel’s serverless functions don’t have one.

What is Next.js SQLite and what will we build?

Next.js runs part of your app on a Node.js server. That server code can open an SQLite database file directly, the same way any Node.js program can, because SQLite runs inside your process with no database server.

To show you how it fits together, I will build a notes app with these files:

FileWhat it does
lib/db.tsOpens notes.db, creates the table, and exports query functions
app/page.tsxA server component that lists the notes
app/actions.tsA server action that adds a note from a form
app/api/notes/route.tsA JSON API for listing, adding and deleting notes

Prerequisites

RequirementVersion used here
Node.js24.18.0
Next.js16.4.0 (App Router, Turbopack)
better-sqlite313.0.3, bundling SQLite 3.53.4

The project’s package.json:

{
  "name": "sqldocs-f0-next-js-sqlite",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "better-sqlite3": "^13.0.3",
    "next": "^16.4.0",
    "react": "^19.3.0",
    "react-dom": "^19.3.0"
  },
  "devDependencies": {
    "@types/better-sqlite3": "^9.6.0",
    "@types/node": "^26.6.4",
    "@types/react": "^19.3.0",
    "typescript": "^7.0.2"
  }
}

Building the notes app

Every file below runs on the server only.

Step 1: Open the database once

lib/db.ts opens the file when the server first imports it, and every request reuses that connection. better-sqlite3 is synchronous, which suits SQLite: a query on a local file doesn’t wait on a network round trip. WAL mode lets readers carry on while a write is in progress (SQLite WAL).

import Database from 'better-sqlite3';

// One connection for the whole server process. The file is created on first use.
const db = new Database('notes.db');
db.pragma('journal_mode = WAL');
db.exec(`CREATE TABLE IF NOT EXISTS notes (
  id INTEGER PRIMARY KEY,
  body TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);

export type Note = { id: number; body: string; created_at: string };

export function listNotes(): Note[] {
  return db.prepare('SELECT id, body, created_at FROM notes ORDER BY id DESC').all() as Note[];
}

export function addNote(body: string): number {
  const info = db.prepare('INSERT INTO notes (body) VALUES (?)').run(body);
  return Number(info.lastInsertRowid);
}

export function deleteNote(id: number): number {
  return db.prepare('DELETE FROM notes WHERE id = ?').run(id).changes;
}

Use ? placeholders for every value, as in addNote. Never build SQL by joining strings with user input.

Step 2: Read the notes in a server component

A server component can call listNotes() directly, with no API in between:

import { listNotes } from '@/lib/db';
import { createNote } from './actions';

// Read the database on every request, not once at build time.
export const dynamic = 'force-dynamic';

export default function Home() {
  const notes = listNotes();
  return (
    <main>
      <h1>Notes</h1>
      <form action={createNote}>
        <input name="body" placeholder="Write a note" />
        <button type="submit">Add</button>
      </form>
      <ul>
        {notes.map((n) => (
          <li key={n.id}>{n.body}</li>
        ))}
      </ul>
    </main>
  );
}

dynamic = 'force-dynamic' matters here. Without it, Next.js can render the page once during next build and serve that snapshot to every visitor.

Step 3: Add notes with a server action

The form posts to a server action, which inserts the row and refreshes the page:

'use server';

import { revalidatePath } from 'next/cache';
import { addNote } from '@/lib/db';

export async function createNote(formData: FormData) {
  const body = String(formData.get('body') ?? '').trim();
  if (body) addNote(body);
  revalidatePath('/');
}

Step 4: Add a JSON API with a route handler

Route handlers export one function per HTTP method:

import { addNote, deleteNote, listNotes } from '@/lib/db';

export async function GET() {
  return Response.json(listNotes());
}

export async function POST(request: Request) {
  const { body } = await request.json();
  if (typeof body !== 'string' || body.trim() === '') {
    return Response.json({ error: 'body is required' }, { status: 400 });
  }
  return Response.json({ id: addNote(body.trim()) }, { status: 201 });
}

export async function DELETE(request: Request) {
  const id = Number(new URL(request.url).searchParams.get('id'));
  return Response.json({ deleted: deleteNote(id) });
}

Running it

Build the app with npx next build. The ƒ marks show both routes are rendered on each request:

✓ Compiled successfully in 4.3s
  Running TypeScript ...
  We detected TypeScript in your project and reconfigured your tsconfig.json file for you.
  Finished TypeScript in 785ms ...
Route (app)
┌ ƒ /
├ ○ /_not-found
└ ƒ /api/notes
○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

Then start it with npx next start -p 3917 and call the API. These two notes are added, one empty note is rejected, and the first note is deleted:

$ curl -s -X POST localhost:3917/api/notes -H 'content-type: application/json' -d '{"body":"Buy milk"}'
{"id":1}
$ curl -s -X POST localhost:3917/api/notes -H 'content-type: application/json' -d '{"body":"Ship the release"}'
{"id":2}
$ curl -s localhost:3917/api/notes
[{"id":2,"body":"Ship the release","created_at":"2026-10-07 08:26:58"},{"id":1,"body":"Buy milk","created_at":"2026-10-07 08:26:58"}]
$ curl -s -X POST localhost:3917/api/notes -H 'content-type: application/json' -d '{"body":""}' -w ' HTTP %{http_code}'
{"error":"body is required"} HTTP 400
$ curl -s -X DELETE 'localhost:3917/api/notes?id=1'
{"deleted":1}

The home page lists what’s left:

$ curl -s localhost:3917/ | grep -o '<li>[^<]*</li>'
<li>Ship the release</li>

The data is an ordinary SQLite file in the project folder, with the WAL files next to it. The sqlite3 shell reads it directly:

$ ls notes.db*
notes.db
notes.db-shm
notes.db-wal
$ sqlite3 -header -column notes.db 'SELECT id, body FROM notes;'
id  body
--  ----------------
2   Ship the release

I tested the API and the page with curl. I didn’t submit the form, which needs a browser to run the server action.

Common problems

The first and third problems below come from the Vercel and Next.js docs. The others follow from the setup above and weren’t reproduced here.

  • Deploying to Vercel or another serverless host: Vercel’s knowledge base says SQLite “can’t be used with Vercel”, because function storage “is ephemeral” and instances don’t share it (Is SQLite supported in Vercel?). Run Next.js on a server or container with a persistent disk, or use a hosted SQLite service such as libSQL/Turso.
  • Module not found: Can't resolve 'fs' in the browser bundle: a client component imported lib/db.ts. Keep database code on the server side.
  • better-sqlite3 bundling errors: Next.js lists better-sqlite3 among the packages it keeps out of the bundle automatically (serverExternalPackages). If you use a different native driver, add it to serverExternalPackages in next.config.
  • The page shows old data: the page was rendered at build time. Add export const dynamic = 'force-dynamic', or call revalidatePath after writes.
  • database is locked: two processes wrote at once, for example next dev and a script. See database is locked.

Conclusion

On a server with a persistent disk, Next.js needs only one SQLite module. Open the database once in lib/db.ts and call it from server code. Use an ORM such as Prisma if you want migrations and typed models, or Node’s built-in node:sqlite if you’d rather skip the native package.

Further reading

Tested with Next.js 16.4.0, better-sqlite3 13.0.3 (SQLite 3.53.4) and Node.js 24.18.0 on 7 October 2026.