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:
- A user visits your website deployed on Vercel
- Vercel routes the request to the nearest edge node
- Your application code (running as an Edge Function or Serverless Function) processes the request
- For database operations, the function connects to the nearest Turso replica using the libSQL client
- The query executes against the local replica for reads, or against the primary for writes
- Turso handles replication automatically, propagating writes to all replicas
- 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:
| 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
| 1 | # Log in to Turso (opens a browser for authentication)
|
| 2 | turso auth login
|
Create Your Database
| 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:
| 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:
| 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:
| 1 | # Apply the schema
|
| 2 | turso db shell my-website-db < schema.sql
|
You can also use the interactive shell to verify:
| 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:
| 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
| 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
| 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:
| 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:
| 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:
| 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
| 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:
| 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 | }
|
| 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
| 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:
| 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
| 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)
- Navigate to vercel.com and sign in with your GitHub account
- Click "Add New" → "Project"
- Select your
my-turso-site repository from the import list
- Configure the project:
- Framework Preset: Next.js (auto-detected)
- Root Directory:
./ (default)
- Build Command:
next build (default)
- Output Directory:
.next (default)
- Before deploying, add environment variables (see below)
- Click "Deploy"
Method 2: CLI Deployment
| 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:
- Go to your project → Settings → Environment Variables
- Add the following:
| 1 | TURSO_DATABASE_URL = libsql://my-website-db-[your-username].turso.io
|
| 2 | TURSO_AUTH_TOKEN = eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...
|
- Ensure both Production, Preview, and Development environments are selected
Via the Vercel CLI:
| 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):
| 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:
| 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:
- Go to Project Settings → Git
- Under Production Branch, confirm it is set to
main (or your preferred branch)
- Under Ignored Build Step, you can add a command to skip builds when only documentation changes:
| 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:
| 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:
- Go to Settings → Branches → Branch protection rules
- Add a rule for
main
- Enable "Require a pull request before merging"
- 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:
| 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:
| 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:
| 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:
| 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:
| 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:
| 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:
| 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
| 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:
| 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
| 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:
| 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:
| 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:
| 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:
| 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:
| 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:
| 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:
| 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
| 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.