$catSERPAPI||~92 min

Complete Guide to Building Websites with Vercel Turso and GitHub

advertisement

Complete Guide to Building Websites with Vercel, Turso, and GitHub

Building modern web applications demands a stack that is fast, scalable, and developer-friendly. The combination of Vercel for deployment and hosting, Turso for distributed edge databases, and GitHub for version control and CI/CD creates a powerful yet approachable trifecta. This guide walks you through every step—from understanding the architecture to deploying a fully functional, database-driven website.

Introduction

Modern web development has shifted toward edge-first architectures where compute and data live close to users. Vercel, the company behind Next.js, provides a globally distributed platform that deploys your frontend and serverless functions to edge nodes worldwide. Turso complements this by offering a distributed SQLite database built on libSQL, replicating your data to edge locations for sub-millisecond query latency. GitHub ties everything together with seamless Git-based deployments, pull request previews, and collaborative workflows.

In this guide, you will learn how to:

  • Understand the architectural principles behind this stack
  • Set up a Next.js project with Turso as the database layer
  • Configure GitHub integrations for automated deployments
  • Implement CRUD operations with real code examples
  • Apply best practices for security, performance, and cost optimization
  • Troubleshoot common pitfalls and avoid production mistakes

Whether you are building a personal blog, a SaaS product, or an internal tool, this guide provides the foundational knowledge and hands-on examples to ship confidently.

Understanding the Architecture

Why This Stack Works

Each component solves a distinct problem, and together they form a cohesive system:

  • GitHub serves as the single source of truth for your codebase. Every push triggers an automated build pipeline.
  • Vercel listens for changes in your GitHub repository, builds your application, and distributes it to a global edge network spanning over 20 regions—from San Francisco to Tokyo.
  • Turso provides a distributed SQLite database that replicates data to edge locations, ensuring your database queries execute with minimal latency regardless of where your users are located.

The key insight is that both Vercel and Turso embrace the edge computing paradigm. Traditional architectures route all database queries to a single centralized server, introducing latency for users far from that data center. Turso replicates your SQLite database to multiple locations, so your Vercel edge functions can read and write data locally.

How the Pieces Connect

Here is the data flow for a typical request:

  1. A user visits your website deployed on Vercel
  2. Vercel routes the request to the nearest edge node
  3. Your application code (running as an Edge Function or Serverless Function) processes the request
  4. For database operations, the function connects to the nearest Turso replica using the libSQL client
  5. The query executes against the local replica for reads, or against the primary for writes
  6. Turso handles replication automatically, propagating writes to all replicas
  7. The response travels back to the user through Vercel's edge network

Comparison with Alternative Approaches

Understanding trade-offs helps you make informed decisions:

Vercel + Turso vs. Vercel + PlanetScale

  • PlanetScale uses MySQL-compatible Vitess and excels at large-scale relational workloads
  • Turso uses SQLite/libSQL and excels at edge-native, low-latency reads
  • PlanetScale recently removed its free tier; Turso offers a generous free tier (9 GB storage, 500 databases)
  • Turso's embedded-replica model is unique—your app can read data without any network call

Vercel + Turso vs. Vercel + Supabase

  • Supabase is a full Backend-as-a-Service with auth, storage, real-time subscriptions, and PostgreSQL
  • Turso is laser-focused on being a fast, distributed database
  • If you need auth and storage out of the box, Supabase is more complete
  • If you need minimal latency at the edge with a lightweight footprint, Turso wins

Vercel + Turso vs. Cloudflare Workers + D1

  • Both are edge-first, SQLite-based approaches
  • D1 is Cloudflare-native and does not work seamlessly with Vercel
  • Turso is provider-agnostic and works with any serverless platform
  • D1 is still in beta with some limitations; Turso is production-ready

Prerequisites and Environment Setup

Before starting, ensure you have the following:

  • Node.js 18.17 or later installed
  • A GitHub account with Git configured locally
  • A Vercel account (free tier works; sign up at vercel.com using your GitHub account for seamless integration)
  • A Turso account (free tier includes 9 GB storage; sign up at turso.tech)

Install the required CLI tools:

bash
1
# Install Turso CLI globally
2
npm install -g @tursodatabase/turso-cli
3
 
4
# Install Vercel CLI globally
5
npm install -g vercel
6
 
7
# Verify installations
8
turso --version
9
vercel --version

Step 1: Create a Turso Database

Turso databases are managed through the CLI or the web dashboard. We will use the CLI for a reproducible workflow.

Authenticate with Turso

bash
1
# Log in to Turso (opens a browser for authentication)
2
turso auth login

Create Your Database

bash
1
# Create a new database
2
turso db create my-website-db
3
 
4
# Output will show the database name and initial location, e.g.:
5
 
6
# Created database my-website-db at arn1 (Stockholm, Sweden)

Retrieve Connection Credentials

Turso uses authentication tokens and a connection URL for programmatic access:

bash
1
# Get the database URL
2
turso db show my-website-db --url
3
 
4
# Output: libsql://my-website-db-[username].turso.io
5
 
6
# Create an authentication token
7
turso db tokens create my-website-db
8
 
9
# Output: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...

Important: Store these credentials securely. You will add them to Vercel as environment variables later. Never commit them to your Git repository.

Set Up the Database Schema

Create a SQL file to define your schema:

sql
1
- - schema.sql
2
 
3
- - Users table for authentication (simplified example)
4
CREATE TABLE IF NOT EXISTS users (
5
  id INTEGER PRIMARY KEY AUTOINCREMENT,
6
  email TEXT NOT NULL UNIQUE,
7
  name TEXT NOT NULL,
8
  avatar_url TEXT,
9
  created_at TEXT DEFAULT (datetime('now'))
10
);
11
 
12
- - Posts table for a blog or content site
13
CREATE TABLE IF NOT EXISTS posts (
14
  id INTEGER PRIMARY KEY AUTOINCREMENT,
15
  title TEXT NOT NULL,
16
  slug TEXT NOT NULL UNIQUE,
17
  content TEXT NOT NULL,
18
  excerpt TEXT,
19
  author_id INTEGER NOT NULL,
20
  published BOOLEAN DEFAULT 0,
21
  created_at TEXT DEFAULT (datetime('now')),
22
  updated_at TEXT DEFAULT (datetime('now')),
23
  FOREIGN KEY (author_id) REFERENCES users(id)
24
);
25
 
26
- - Index for fast slug lookups
27
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
28
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published);

Execute the schema against your database:

bash
1
# Apply the schema
2
turso db shell my-website-db < schema.sql

You can also use the interactive shell to verify:

bash
1
turso db shell my-website-db
2
 
3
# Inside the shell:
4
.tables
5
 
6
# Expected output: posts  users
7
 
8
.schema posts
9
 
10
# Shows the CREATE TABLE statement

Add Edge Replicas (Optional but Recommended)

For production workloads, add replicas in regions close to your users:

bash
1
# Add a replica in Tokyo
2
turso db replicate my-website-db hnd1
3
 
4
# Add a replica in London
5
turso db replicate my-website-db lhr1
6
 
7
# Add a replica in San Francisco
8
turso db replicate my-website-db sfo1
9
 
10
# View all locations
11
turso db show my-website-db

Turso automatically routes read queries to the nearest replica and writes to the primary. This happens transparently—your application code does not need to change.

Step 2: Initialize a Next.js Project

Next.js is the framework Vercel is optimized for, and it provides the best developer experience on the platform.

Create the Project

bash
1
# Create a new Next.js project
2
npx create-next-app@latest my-turso-site
3
 
4
# During setup, select:
5
 
6
# ✔ TypeScript: Yes
7
 
8
# ✔ ESLint: Yes
9
 
10
# ✔ Tailwind CSS: Yes
11
 
12
# ✔ src/ directory: Yes
13
 
14
# ✔ App Router: Yes (recommended)
15
 
16
# ✔ Import alias: @/*
17
 
18
# Navigate into the project
19
cd my-turso-site

Install Turso Client Libraries

bash
1
# Install the libSQL client for Node.js
2
npm install @libsql/client
3
 
4
# Install Drizzle ORM (optional but recommended for type safety)
5
npm install drizzle-orm
6
npm install -D drizzle-kit

Why Drizzle ORM? While you can use raw SQL with @libsql/client, Drizzle provides type-safe query building, automatic migration management, and excellent TypeScript inference. For production applications, the type safety alone prevents entire categories of bugs.

Configure Database Connection

Create a centralized database configuration file:

typescript
1
// src/lib/db.ts
2
 
3
import { createClient } from '@libsql/client';
4
import { drizzle } from 'drizzle-orm/libsql';
5
 
6
// Initialize the libSQL client using environment variables
7
const client = createClient({
8
  url: process.env.TURSO_DATABASE_URL!,
9
  authToken: process.env.TURSO_AUTH_TOKEN!,
10
});
11
 
12
// Create the Drizzle instance
13
export const db = drizzle(client);
14
 
15
export default client;

Define Drizzle Schema

Create a schema definition that mirrors your database:

typescript
1
// src/lib/schema.ts
2
 
3
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
4
 
5
export const users = sqliteTable('users', {
6
  id: integer('id').primaryKey({ autoIncrement: true }),
7
  email: text('email').notNull().unique(),
8
  name: text('name').notNull(),
9
  avatarUrl: text('avatar_url'),
10
  createdAt: text('created_at').default(
11
    // SQLite datetime function
12
    `(datetime('now'))`
13
  ),
14
});
15
 
16
export const posts = sqliteTable('posts', {
17
  id: integer('id').primaryKey({ autoIncrement: true }),
18
  title: text('title').notNull(),
19
  slug: text('slug').notNull().unique(),
20
  content: text('content').notNull(),
21
  excerpt: text('excerpt'),
22
  authorId: integer('author_id').notNull(),
23
  published: integer('published', { mode: 'boolean' }).default(false),
24
  createdAt: text('created_at').default(
25
    `(datetime('now'))`
26
  ),
27
  updatedAt: text('updated_at').default(
28
    `(datetime('now'))`
29
  ),
30
});

Step 3: Build Application Features

Reading Posts (Server Component)

Next.js App Router with server components is perfect for database reads—no client-side JavaScript needed for the initial page load:

typescript
1
// src/app/page.tsx
2
 
3
import { db } from '@/lib/db';
4
import { posts, users } from '@/lib/schema';
5
import { eq, desc } from 'drizzle-orm';
6
import Link from 'next/link';
7
 
8
// This component runs entirely on the server
9
export default async function HomePage() {
10
  // Fetch published posts with author information
11
  const allPosts = await db
12
    .select({
13
      id: posts.id,
14
      title: posts.title,
15
      slug: posts.slug,
16
      excerpt: posts.excerpt,
17
      createdAt: posts.createdAt,
18
      authorName: users.name,
19
    })
20
    .from(posts)
21
    .innerJoin(users, eq(posts.authorId, users.id))
22
    .where(eq(posts.published, true))
23
    .orderBy(desc(posts.createdAt));
24
 
25
  return (
26
    <main className="max-w-4xl mx-auto px-6 py-12">
27
      <h1 className="text-4xl font-bold mb-8">Latest Posts</h1>
28
      
29
      {allPosts.length === 0 ? (
30
        <p className="text-gray-500">No posts yet. Check back soon!</p>
31
      ) : (
32
        <div className="space-y-8">
33
          {allPosts.map((post) => (
34
            <article key={post.id} className="border-b pb-6">
35
              <Link href={`/posts/${post.slug}`}>
36
                <h2 className="text-2xl font-semibold hover:text-blue-600 transition-colors">
37
                  {post.title}
38
                </h2>
39
              </Link>
40
              {post.excerpt && (
41
                <p className="text-gray-600 mt-2">{post.excerpt}</p>
42
              )}
43
              <div className="text-sm text-gray-400 mt-2">
44
                By {post.authorName} · {post.createdAt}
45
              </div>
46
            </article>
47
          ))}
48
        </div>
49
      )}
50
    </main>
51
  );
52
}

Individual Post Page with Dynamic Routing

typescript
1
// src/app/posts/[slug]/page.tsx
2
 
3
import { db } from '@/lib/db';
4
import { posts, users } from '@/lib/schema';
5
import { eq } from 'drizzle-orm';
6
import { notFound } from 'next/navigation';
7
 
8
// Generate static params for build-time pre-rendering
9
export async function generateStaticParams() {
10
  const allPosts = await db
11
    .select({ slug: posts.slug })
12
    .from(posts)
13
    .where(eq(posts.published, true));
14
 
15
  return allPosts.map((post) => ({
16
    slug: post.slug,
17
  }));
18
}
19
 
20
export default async function PostPage({
21
  params,
22
}: {
23
  params: Promise<{ slug: string }>;
24
}) {
25
  const { slug } = await params;
26
 
27
  const result = await db
28
    .select({
29
      id: posts.id,
30
      title: posts.title,
31
      content: posts.content,
32
      createdAt: posts.createdAt,
33
      authorName: users.name,
34
    })
35
    .from(posts)
36
    .innerJoin(users, eq(posts.authorId, users.id))
37
    .where(eq(posts.slug, slug))
38
    .limit(1);
39
 
40
  const post = result[0];
41
 
42
  if (!post) {
43
    notFound();
44
  }
45
 
46
  return (
47
    <article className="max-w-3xl mx-auto px-6 py-12">
48
      <header className="mb-8">
49
        <h1 className="text-4xl font-bold">{post.title}</h1>
50
        <div className="text-gray-500 mt-2">
51
          By {post.authorName} · {post.createdAt}
52
        </div>
53
      </header>
54
      <div className="prose prose-lg max-w-none">
55
        {/* In production, use a markdown renderer for content */}
56
        <p>{post.content}</p>
57
      </div>
58
    </article>
59
  );
60
}

Creating Posts (Server Action with Form)

Server Actions in Next.js provide a clean way to handle form submissions without writing API routes:

typescript
1
// src/app/admin/new-post/actions.ts
2
 
3
'use server';
4
 
5
import { db } from '@/lib/db';
6
import { posts } from '@/lib/schema';
7
import { redirect } from 'next/navigation';
8
import { revalidatePath } from 'next/cache';
9
 
10
export async function createPost(formData: FormData) {
11
  const title = formData.get('title') as string;
12
  const slug = formData.get('slug') as string;
13
  const content = formData.get('content') as string;
14
  const excerpt = formData.get('excerpt') as string;
15
  const published = formData.get('published') === 'on';
16
 
17
  // Basic validation
18
  if (!title || !slug || !content) {
19
    throw new Error('Title, slug, and content are required');
20
  }
21
 
22
  // Insert the new post
23
  await db.insert(posts).values({
24
    title,
25
    slug,
26
    content,
27
    excerpt: excerpt || null,
28
    authorId: 1, // In production, get from session/auth
29
    published,
30
  });
31
 
32
  // Revalidate the home page cache to show the new post
33
  revalidatePath('/');
34
  revalidatePath('/posts');
35
 
36
  redirect('/admin');
37
}
typescript
1
// src/app/admin/new-post/page.tsx
2
 
3
import { createPost } from './actions';
4
 
5
export default function NewPostPage() {
6
  return (
7
    <main className="max-w-2xl mx-auto px-6 py-12">
8
      <h1 className="text-3xl font-bold mb-8">Create New Post</h1>
9
      
10
      <form action={createPost} className="space-y-6">
11
        <div>
12
          <label htmlFor="title" className="block text-sm font-medium mb-1">
13
            Title
14
          </label>
15
          <input
16
            type="text"
17
            id="title"
18
            name="title"
19
            required
20
            className="w-full border rounded-lg px-4 py-2"
21
          />
22
        </div>
23
 
24
        <div>
25
          <label htmlFor="slug" className="block text-sm font-medium mb-1">
26
            URL Slug
27
          </label>
28
          <input
29
            type="text"
30
            id="slug"
31
            name="slug"
32
            required
33
            placeholder="my-awesome-post"
34
            className="w-full border rounded-lg px-4 py-2"
35
          />
36
        </div>
37
 
38
        <div>
39
          <label htmlFor="excerpt" className="block text-sm font-medium mb-1">
40
            Excerpt
41
          </label>
42
          <textarea
43
            id="excerpt"
44
            name="excerpt"
45
            rows={2}
46
            className="w-full border rounded-lg px-4 py-2"
47
          />
48
        </div>
49
 
50
        <div>
51
          <label htmlFor="content" className="block text-sm font-medium mb-1">
52
            Content
53
          </label>
54
          <textarea
55
            id="content"
56
            name="content"
57
            required
58
            rows={12}
59
            className="w-full border rounded-lg px-4 py-2"
60
          />
61
        </div>
62
 
63
        <div className="flex items-center gap-2">
64
          <input type="checkbox" id="published" name="published" />
65
          <label htmlFor="published" className="text-sm">
66
            Publish immediately
67
          </label>
68
        </div>
69
 
70
        <button
71
          type="submit"
72
          className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors"
73
        >
74
          Create Post
75
        </button>
76
      </form>
77
    </main>
78
  );
79
}

Step 4: Push to GitHub

With your application code ready, it is time to version-control everything and prepare for deployment.

Initialize Git and Create the Repository

bash
1
# Initialize Git (if not already done by create-next-app)
2
git init
3
 
4
# Stage all files
5
git add .
6
 
7
# Create the initial commit
8
git commit -m "feat: initial Next.js project with Turso integration"

Create a .gitignore File

Ensure sensitive files and build artifacts are excluded:

gitignore
1
# .gitignore
2
 
3
# Dependencies
4
node_modules/
5
.pnp
6
.pnp.js
7
 
8
# Build outputs
9
.next/
10
out/
11
build/
12
dist/
13
 
14
# Environment variables (CRITICAL)
15
.env
16
.env.local
17
.env.production.local
18
.env.development.local
19
 
20
# Debug logs
21
npm-debug.log*
22
yarn-debug.log*
23
yarn-error.log*
24
 
25
# IDE
26
.vscode/
27
.idea/
28
* .swp
29
* .swo
30
 
31
# OS files
32
.DS_Store
33
Thumbs.db
34
 
35
# Turso
36
* .db
37
* .db-journal

Create the Repository on GitHub

bash
1
# Using GitHub CLI (recommended)
2
gh repo create my-turso-site --public --source=. --push
3
 
4
# Or manually:
5
 
6
# 1. Go to github.com/new
7
 
8
# 2. Create a repository named "my-turso-site"
9
 
10
# 3. Follow the instructions to add the remote and push
11
 
12
git remote add origin https://github.com/YOUR_USERNAME/my-turso-site.git
13
git branch -M main
14
git push -u origin main

Step 5: Deploy to Vercel

Method 1: Dashboard Deployment (Recommended for First-Timers)

  1. Navigate to vercel.com and sign in with your GitHub account
  2. Click "Add New""Project"
  3. Select your my-turso-site repository from the import list
  4. Configure the project:
    • Framework Preset: Next.js (auto-detected)
    • Root Directory: ./ (default)
    • Build Command: next build (default)
    • Output Directory: .next (default)
  5. Before deploying, add environment variables (see below)
  6. Click "Deploy"

Method 2: CLI Deployment

bash
1
# Deploy from your project directory
2
vercel
3
 
4
# Follow the prompts:
5
 
6
# ? Set up and deploy "~/my-turso-site"? [Y/n] Y
7
 
8
# ? Which scope do you want to deploy to? Your Account
9
 
10
# ? Link to existing project? [y/N] N
11
 
12
# ? What's your project's name? my-turso-site
13
 
14
# ? In which directory is your code located? ./
15
 
16
# ? Want to modify these settings? [y/N] N

Configure Environment Variables

This is the most critical step. Your application needs the Turso credentials to connect to the database at runtime.

Via the Vercel Dashboard:

  1. Go to your project → SettingsEnvironment Variables
  2. Add the following:
code
1
TURSO_DATABASE_URL = libsql://my-website-db-[your-username].turso.io
2
TURSO_AUTH_TOKEN = eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...
  1. Ensure both Production, Preview, and Development environments are selected

Via the Vercel CLI:

bash
1
# Add environment variables through the CLI
2
vercel env add TURSO_DATABASE_URL
3
 
4
# Paste the value when prompted, select all environments
5
 
6
vercel env add TURSO_AUTH_TOKEN
7
 
8
# Paste the token when prompted, select all environments

Via the Vercel API (for automation):

bash
1
# Using the Vercel API with a token
2
curl -X POST "https://api.vercel.com/v9/projects/my-turso-site/env" \
3
  - H "Authorization: Bearer YOUR_VERCEL_TOKEN" \
4
  - H "Content-Type: application/json" \
5
  - d '{
6
    "key": "TURSO_DATABASE_URL",
7
    "value": "libsql://my-website-db-username.turso.io",
8
    "target": ["production", "preview", "development"],
9
    "type": "encrypted"
10
  }'

Redeploy After Adding Variables

If you added environment variables after the initial deployment, trigger a redeployment:

bash
1
# Redeploy using the CLI
2
vercel --prod
3
 
4
# Or push a new commit to trigger automatic deployment
5
git commit --allow-empty -m "chore: trigger redeploy with env vars"
6
git push

Step 6: Configure the GitHub-Vercel Integration

The GitHub integration provides powerful workflow features beyond simple deployment.

Automatic Deployments

By default, Vercel deploys automatically on every push to the main branch. You can configure this:

  1. Go to Project SettingsGit
  2. Under Production Branch, confirm it is set to main (or your preferred branch)
  3. Under Ignored Build Step, you can add a command to skip builds when only documentation changes:
bash
1
# Only build if source files changed
2
git diff HEAD^ HEAD --quiet . && exit 1 || exit 0

Preview Deployments for Pull Requests

Every pull request automatically gets a preview deployment with a unique URL. This is invaluable for code review:

  • Reviewers can see a live version of the changes
  • Database operations use the same Turso database (or a separate preview database)
  • Preview URLs are shareable with stakeholders

Creating a preview database for PRs:

bash
1
# Create a separate database for staging/preview
2
turso db create my-website-db-preview
3
 
4
# Get its credentials
5
turso db show my-website-db-preview --url
6
turso db tokens create my-website-db-preview
7
 
8
# Add as a separate environment variable for the Preview environment
9
 
10
# In Vercel: Settings → Environment Variables
11
 
12
# TURSO_DATABASE_URL (Preview) = libsql://my-website-db-preview-username.turso.io

This pattern ensures that pull request previews do not affect your production data.

Branch Protection and Deployment Gates

In your GitHub repository settings:

  1. Go to SettingsBranchesBranch protection rules
  2. Add a rule for main
  3. Enable "Require a pull request before merging"
  4. Optionally enable "Require status checks to pass" and select Vercel's deployment check

Step 7: Advanced Configuration

Connection Pooling for Serverless Functions

Serverless functions can create many concurrent database connections. Turso handles this well due to its HTTP-based protocol, but you should still be mindful of connection management:

typescript
1
// src/lib/db.ts - Production-ready configuration
2
 
3
import { createClient } from '@libsql/client';
4
import { drizzle } from 'drizzle-orm/libsql';
5
 
6
// Singleton pattern to prevent multiple client instances
7
// in the same serverless function invocation
8
const globalForDb = globalThis as unknown as {
9
  client: ReturnType<typeof createClient> | undefined;
10
};
11
 
12
const client = globalForDb.client ?? createClient({
13
  url: process.env.TURSO_DATABASE_URL!,
14
  authToken: process.env.TURSO_AUTH_TOKEN!,
15
  // Connection options for serverless environments
16
  concurrency: 10, // Max concurrent HTTP requests to Turso
17
});
18
 
19
// In development, prevent multiple clients from hot reloads
20
if (process.env.NODE_ENV !== 'production') {
21
  globalForDb.client = client;
22
}
23
 
24
export const db = drizzle(client);
25
export default client;

Using Embedded Replicas for Offline-First Patterns

Turso's unique embedded replica feature allows you to embed a local SQLite file within your application. This enables offline reads and automatic background synchronization:

typescript
1
// src/lib/db-embedded.ts
2
// Note: Embedded replicas work in Node.js environments
3
// (Serverless Functions), not Edge Functions
4
 
5
import { createClient } from '@libsql/client';
6
 
7
const client = createClient({
8
  url: 'file:local.db',               // Local SQLite file
9
  authToken: process.env.TURSO_AUTH_TOKEN,
10
  syncUrl: process.env.TURSO_DATABASE_URL, // Remote Turso database
11
  syncInterval: 60, // Sync every 60 seconds
12
});
13
 
14
// Trigger an initial sync on startup
15
await client.sync();
16
 
17
export default client;

This approach is powerful for read-heavy workloads because all reads happen against the local file with zero network latency. Writes are sent to the primary and synced back to the embedded replica.

Edge Function Compatibility

If you want to use Vercel Edge Functions (even faster cold starts, globally distributed), use the standard HTTP-based client:

typescript
1
// src/lib/db-edge.ts - Compatible with Edge Runtime
2
 
3
import { createClient } from '@libsql/client/web'; // Note the /web import
4
 
5
const client = createClient({
6
  url: process.env.TURSO_DATABASE_URL!,
7
  authToken: process.env.TURSO_AUTH_TOKEN!,
8
});
9
 
10
export default client;

You can then use this in an Edge API route:

typescript
1
// src/app/api/posts/route.ts
2
 
3
import { NextResponse } from 'next/server';
4
import client from '@/lib/db-edge';
5
 
6
export const runtime = 'edge'; // Enable Edge Runtime
7
 
8
export async function GET() {
9
  try {
10
    const result = await client.execute(
11
      'SELECT id, title, slug, excerpt FROM posts WHERE published = 1 ORDER BY created_at DESC LIMIT 10'
12
    );
13
 
14
    return NextResponse.json({ posts: result.rows });
15
  } catch (error) {
16
    console.error('Database query failed:', error);
17
    return NextResponse.json(
18
      { error: 'Failed to fetch posts' },
19
      { status: 500 }
20
    );
21
  }
22
}

Step 8: Monitoring and Observability

Vercel Logging

Vercel provides real-time logs for your deployments:

bash
1
# Stream production logs
2
vercel logs --prod --follow
3
 
4
# Stream logs for a specific deployment
5
vercel logs [deployment-url] --follow

Turso Monitoring

Monitor your database health through the Turso platform:

bash
1
# Check database status
2
turso db show my-website-db
3
 
4
# View group information (replicas)
5
turso group list
6
 
7
# Monitor database usage
8
turso db usage my-website-db

The Turso dashboard at turso.tech/app provides visual metrics for:

  • Query latency percentiles
  • Read/write operation counts
  • Storage utilization
  • Replica sync status

Structured Error Handling

Implement consistent error handling across your application:

typescript
1
// src/lib/db-errors.ts
2
 
3
export class DatabaseError extends Error {
4
  constructor(
5
    message: string,
6
    public readonly cause: unknown,
7
    public readonly statusCode: number = 500
8
  ) {
9
    super(message);
10
    this.name = 'DatabaseError';
11
  }
12
}
13
 
14
export function handleDbError(error: unknown): DatabaseError {
15
  if (error instanceof Error) {
16
    // libSQL-specific error patterns
17
    if (error.message.includes('UNIQUE constraint failed')) {
18
      return new DatabaseError('A record with this identifier already exists', error, 409);
19
    }
20
    if (error.message.includes('no such table')) {
21
      return new DatabaseError('Database schema is not initialized', error, 500);
22
    }
23
    if (error.message.includes('SQLITE_BUSY')) {
24
      return new DatabaseError('Database is temporarily busy, please retry', error, 503);
25
    }
26
  }
27
  
28
  return new DatabaseError('An unexpected database error occurred', error, 500);
29
}

Best Practices and Common Pitfalls

Security Best Practices

1. Mark Environment Variables as Sensitive in Vercel

When adding your Turso auth token, ensure the "Sensitive" option is checked. This prevents the value from appearing in build logs and restricts access to runtime only.

2. Rotate Tokens Regularly

bash
1
# Revoke an old token
2
turso db tokens invalidate my-website-db
3
 
4
# Create a new token
5
turso db tokens create my-website-db
6
 
7
# Update the value in Vercel dashboard

3. Use Row-Level Security Patterns

SQLite does not have built-in row-level security like PostgreSQL, but you can enforce it at the application layer:

typescript
1
// Always filter queries by the authenticated user's ID
2
const userPosts = await db
3
  .select()
4
  .from(posts)
5
  .where(eq(posts.authorId, session.user.id)); // Never trust client-sent author IDs

4. Validate All Inputs

typescript
1
// Use Zod for schema validation
2
import { z } from 'zod';
3
 
4
const createPostSchema = z.object({
5
  title: z.string().min(1).max(200),
6
  slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
7
  content: z.string().min(1).max(100000),
8
  excerpt: z.string().max(500).optional(),
9
  published: z.boolean().default(false),
10
});
11
 
12
// In your server action:
13
const validated = createPostSchema.parse({
14
  title: formData.get('title'),
15
  slug: formData.get('slug'),
16
  content: formData.get('content'),
17
  excerpt: formData.get('excerpt'),
18
  published: formData.get('published') === 'on',
19
});

Performance Best Practices

1. Add Proper Indexes

SQLite queries are only fast when the right indexes exist. Always index columns used in WHERE clauses, JOIN conditions, and ORDER BY expressions:

sql
1
- - Index for common query patterns
2
CREATE INDEX idx_posts_author_published ON posts(author_id, published);
3
CREATE INDEX idx_posts_created_published ON posts(created_at DESC, published);

2. Use Select Wisely

Never use SELECT * in production. Select only the columns you need:

typescript
1
// Bad: fetches all columns including potentially large content blobs
2
const posts = await db.select().from(postsTable);
3
 
4
// Good: fetches only what the listing page needs
5
const postSummaries = await db
6
  .select({
7
    id: postsTable.id,
8
    title: postsTable.title,
9
    slug: postsTable.slug,
10
    excerpt: postsTable.excerpt,
11
  })
12
  .from(postsTable)
13
  .where(eq(postsTable.published, true));

3. Implement Pagination

For tables with many rows, always paginate:

typescript
1
// src/lib/pagination.ts
2
 
3
export async function getPaginatedPosts(page: number = 1, pageSize: number = 10) {
4
  const offset = (page - 1) * pageSize;
5
 
6
  const [items, countResult] = await Promise.all([
7
    db
8
      .select({
9
        id: posts.id,
10
        title: posts.title,
11
        slug: posts.slug,
12
        excerpt: posts.excerpt,
13
        createdAt: posts.createdAt,
14
      })
15
      .from(posts)
16
      .where(eq(posts.published, true))
17
      .orderBy(desc(posts.createdAt))
18
      .limit(pageSize)
19
      .offset(offset),
20
    db
21
      .select({ count: sql<number>`count(*)` })
22
      .from(posts)
23
      .where(eq(posts.published, true)),
24
  ]);
25
 
26
  const totalItems = Number(countResult[0].count);
27
  const totalPages = Math.ceil(totalItems / pageSize);
28
 
29
  return {
30
    items,
31
    pagination: {
32
      currentPage: page,
33
      totalPages,
34
      totalItems,
35
      hasNextPage: page < totalPages,
36
      hasPrevPage: page > 1,
37
    },
38
  };
39
}

Common Pitfalls to Avoid

Pitfall 1: Deploying Without Environment Variables

This is the most common cause of deployment failures. Always verify your environment variables are set before deploying:

bash
1
# List Vercel environment variables
2
vercel env ls

Pitfall 2: Using the Wrong Import Path for Edge Runtime

The standard @libsql/client import does not work in Edge Functions. Use @libsql/client/web for edge-compatible builds. The error message is usually cryptic—a missing Node.js built-in module.

Pitfall 3: Not Handling Connection Failures Gracefully

Turso is highly available, but network issues can occur. Always wrap database calls in try-catch blocks and provide fallback UX:

typescript
1
export default async function HomePage() {
2
  try {
3
    const allPosts = await db.select().from(posts).where(eq(posts.published, true));
4
    return <PostList posts={allPosts} />;
5
  } catch (error) {
6
    console.error('Failed to fetch posts:', error);
7
    return (
8
      <div className="text-center py-12">
9
        <h2 className="text-xl font-semibold text-red-600">
10
          Unable to load posts
11
        </h2>
12
        <p className="text-gray-500 mt-2">
13
          Please try refreshing the page.
14
        </p>
15
      </div>
16
    );
17
  }
18
}

Pitfall 4: Exceeding Turso Free Tier Limits

The Turso free tier includes 9 GB total storage across all databases, 500 databases, and 25 billion row reads per month. Monitor your usage and set up alerts. If you approach limits, consider upgrading or archiving old data.

Cost Considerations

Understanding the cost structure of each service helps you plan:

Vercel Pricing:

  • Hobby (Free): 1 team member, 100 GB bandwidth, unlimited deployments, community support
  • Pro ($20/month per member): 1 TB bandwidth, unlimited duration logs, password protection, analytics
  • Enterprise: Custom pricing, SAML SSO, advanced security, SLA

Turso Pricing:

  • Free (Starter): 9 GB storage, 500 databases, 25 billion row reads, 3 million row writes
  • Scalen (pay-as-you-go): $0.75 per million read units, $3 per million write units, $0.30/GB/month storage
  • Enterprise: Custom pricing, dedicated support, compliance features

For a typical blog or small SaaS application, the free tiers of both services are often sufficient for months or even years of operation.

Troubleshooting Guide

Deployment Fails with "Cannot Find Module"

This usually means your dependencies are not installed or there is an import path issue:

bash
1
# Verify all dependencies are in package.json
2
cat package.json | grep -A 50 '"dependencies"'
3
 
4
# Ensure @libsql/client is listed
5
npm ls @libsql/client
6
 
7
# If missing, install and commit
8
npm install @libsql/client
9
git add package.json package-lock.json
10
git commit -m "fix: add missing libsql dependency"
11
git push

Build Succeeds but Runtime Returns "Database Connection Error"

Check your environment variables are set correctly:

typescript
1
// Add a debug API route temporarily
2
// src/app/api/debug/route.ts
3
 
4
import { NextResponse } from 'next/server';
5
 
6
export async function GET() {
7
  return NextResponse.json({
8
    hasUrl: !!process.env.TURSO_DATABASE_URL,
9
    hasToken: !!process.env.TURSO_AUTH_TOKEN,
10
    urlPrefix: process.env.TURSO_DATABASE_URL?.substring(0, 20),
11
    nodeEnv: process.env.NODE_ENV,
12
  });
13
}

Visit /api/debug to verify the environment. Remove this route before going to production.

Slow Query Performance

bash
1
# Use the Turso shell to analyze queries
2
turso db shell my-website-db
3
 
4
# Use EXPLAIN QUERY PLAN to see how SQLite executes your query
5
EXPLAIN QUERY PLAN SELECT * FROM posts WHERE published = 1 ORDER BY created_at DESC;

If the plan shows a full table scan, you need an index. Look for "SCAN TABLE" in the output—this indicates missing indexes.

Conclusion

The Vercel + Turso + GitHub stack represents a modern, efficient approach to building web applications. By leveraging Vercel's global edge network, Turso's distributed SQLite architecture, and GitHub's seamless CI/CD integration, you can build and deploy database-driven websites that are fast anywhere in the world.

Key takeaways from this guide:

  • Start with the free tiers of both Vercel and Turso—they are generous enough for most side projects and MVPs
  • Use environment variables in Vercel for all sensitive credentials, and mark them as sensitive
  • Leverage Drizzle ORM for type-safe database queries that catch errors at compile time
  • Set up preview databases for pull request isolation and safe testing
  • Add indexes proactively based on your actual query patterns
  • Use Edge Runtime with the @libsql/client/web import for the lowest possible latency
  • Implement proper error handling with graceful degradation for database failures
  • Monitor your usage on both platforms to stay within limits and plan for scaling

Recommended next steps for further learning:

  • Explore Vercel's AI SDK for adding AI-powered features to your application
  • Investigate Turso's embedded replicas for offline-first architectures in mobile or desktop apps
  • Set up Vercel Analytics to monitor real-user performance metrics
  • Implement authentication using NextAuth.js or Clerk to protect your admin routes
  • Consider Vercel Blob for file storage alongside your Turso database

The combination of edge computing, distributed databases, and Git-driven deployments is not just a trend—it is the foundation of how web applications will be built for years to come. Start building today, and iterate as you learn.

advertisement

Complete Guide to Building Websites with Vercel Turso and GitHub — AI Hub