Setting Up SQLite with better-sqlite3
better-sqlite3 is a fast, synchronous SQLite driver for Node.js. Unlike most database drivers that are async, better-sqlite3 is synchronous — which aligns perfectly with SQLite’s single-file, zero-network design and makes code simpler.
Installing
Section titled “Installing”npm install better-sqlite3npm install --save-dev @types/better-sqlite3Connecting to a database
Section titled “Connecting to a database”import { join } from 'path'import Database from 'better-sqlite3'import { config } from '../config.js'
const dbPath = join(process.cwd(), config.dbPath)export const db = new Database(dbPath)
// Enable WAL mode for better concurrent read performancedb.pragma('journal_mode = WAL')new Database(path) opens the database file if it exists, or creates it if it doesn’t. The database file is created at config.dbPath (typically ./bulletin.db).
Your first query
Section titled “Your first query”import { db } from './db/index.js'
// .run() for INSERT/UPDATE/DELETEconst result = db.prepare('INSERT INTO users (username) VALUES (?)').run('alice')console.log(result.lastInsertRowid) // the new row's ID
// .get() for SELECT that returns one rowconst user = db.prepare('SELECT * FROM users WHERE id = ?').get(1)console.log(user) // { id: 1, username: 'alice', ... }
// .all() for SELECT that returns all rowsconst users = db.prepare('SELECT * FROM users').all()console.log(users) // [{ id: 1, ... }, { id: 2, ... }]Prepared statements
Section titled “Prepared statements”db.prepare(sql) creates a prepared statement — a pre-compiled SQL query. This is more efficient than building SQL strings, and critically, it prevents SQL injection (more on this shortly).
Store frequently-used statements:
import { db } from './index.js'
export const queries = { getPostById: db.prepare('SELECT * FROM posts WHERE id = ?'), listPosts: db.prepare('SELECT * FROM posts ORDER BY created_at DESC'), createPost: db.prepare( 'INSERT INTO posts (title, body, user_id) VALUES (@title, @body, @userId)' ),}Named parameters
Section titled “Named parameters”Instead of positional ? placeholders, use named parameters with @name:
const createPost = db.prepare( 'INSERT INTO posts (title, body, user_id) VALUES (@title, @body, @userId)')
createPost.run({ title: 'Hello', body: 'World', userId: 1 })Named parameters are clearer when there are multiple values.
SQL injection prevention
Section titled “SQL injection prevention”Never build SQL strings with string interpolation:
// ❌ SQL injection vulnerabilityconst userId = req.params.id // attacker sends: "1; DROP TABLE users;"db.prepare(`SELECT * FROM users WHERE id = ${userId}`).get()Always use parameterized queries:
// ✅ Safe — the value is sanitized automaticallydb.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id)better-sqlite3 escapes all parameter values, making injection impossible.
TypeScript types
Section titled “TypeScript types”Type the return values of your queries:
interface Post { id: number title: string body: string user_id: number upvotes: number created_at: string}
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id) as Post | undefinedExercise
Section titled “Exercise”- Install
better-sqlite3. - Create
src/db/index.tswith the database connection. - Create a temporary script that runs an INSERT and a SELECT. Verify you can write and read data.
- Open the
.dbfile with DB Browser for SQLite (free GUI) and inspect the data visually.
new Database(path)connects to or creates a SQLite file..prepare(sql)creates a prepared statement — use it for all queries..run(params)executes INSERT/UPDATE/DELETE;.get(params)returns one row;.all(params)returns all rows.- Always use parameterized queries (
?or@name) — never string interpolation. db.pragma('journal_mode = WAL')improves concurrent read performance.