Full-Stack Vercel

Build a full-stack app on Vercel with Vercel Postgres (Neon), KV (Upstash Redis), and Blob storage. This recipe covers project setup, database provisioning, and accessing services from loaders and API routes.

What You Get

Vercel provides managed infrastructure you can connect to from Edge Functions:

  • Vercel Postgres โ€” Serverless Postgres powered by Neon, accessible via @vercel/postgres
  • Vercel KV โ€” Redis-compatible store powered by Upstash
  • Vercel Blob โ€” File storage with a simple upload/download API

pracht's Vercel adapter deploys your app as an Edge Function with SSG pages served from the CDN.

1. Project Setup

# Create a new pracht app
pnpm create pracht my-app
cd my-app

# Install the Vercel adapter and storage SDKs
pnpm add @pracht/adapter-vercel @vercel/postgres @vercel/kv
vite.config.ts
import { defineConfig } from "vite";
import { pracht } from "@pracht/vite-plugin";
import { vercelAdapter } from "@pracht/adapter-vercel";

export default defineConfig({
  plugins: [pracht({ adapter: vercelAdapter() })],
});

2. Provision a Database

Create a Postgres database from the Vercel dashboard or CLI:

npx vercel link
npx vercel env pull .env.local

After linking a Vercel Postgres store, your .env.local will contain POSTGRES_URL and related connection strings. These are automatically available as environment variables in production.

3. Database Schema

Use any migration tool you like. Here's a simple approach with @vercel/postgres:

scripts/migrate.ts
import { sql } from "@vercel/postgres";

async function migrate() {
  await sql`
    CREATE TABLE IF NOT EXISTS posts (
      id SERIAL PRIMARY KEY,
      title TEXT NOT NULL,
      body TEXT NOT NULL,
      created_at TIMESTAMPTZ DEFAULT NOW()
    )
  `;
  console.log("Migration complete");
}

migrate();
# Run locally with .env.local loaded
npx dotenv -e .env.local -- npx tsx scripts/migrate.ts

4. Type Your Context

src/env.d.ts
declare module "pracht" {
  interface PrachtContext {
    // Vercel's edge context is available here
    // Add any custom context from createContext
  }
}

5. Query Postgres in Loaders

@vercel/postgres reads connection info from environment variables automatically โ€” no binding wiring needed:

src/routes/posts.tsx
import { sql } from "@vercel/postgres";
import type { LoaderArgs, RouteComponentProps } from "pracht";

interface Post {
  id: number;
  title: string;
  body: string;
  created_at: string;
}

export async function loader(_args: LoaderArgs) {
  const { rows } = await sql<Post>`
    SELECT id, title, body, created_at
    FROM posts
    ORDER BY created_at DESC
  `;

  return { posts: rows };
}

export function Component({ data }: RouteComponentProps<typeof loader>) {
  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {data.posts.map((post) => (
          <li key={post.id}>
            <a href={`/posts/${post.id}`}>{post.title}</a>
            <time>{post.created_at}</time>
          </li>
        ))}
      </ul>
      <a href="/posts/new">New Post</a>
    </div>
  );
}

6. Mutations via API Routes

src/api/posts.ts
import { sql } from "@vercel/postgres";
import type { ApiRouteArgs } from "pracht";

export async function POST({ request }: ApiRouteArgs) {
  const form = await request.formData();
  const title = String(form.get("title") ?? "");
  const body = String(form.get("body") ?? "");

  if (!title || !body) {
    return Response.json({ error: "Title and body are required" }, { status: 400 });
  }

  const { rows } = await sql`
    INSERT INTO posts (title, body)
    VALUES (${title}, ${body})
    RETURNING id
  `;

  return new Response(null, {
    status: 302,
    headers: { location: `/posts/${rows[0].id}` },
  });
}

Use a form to submit:

src/routes/posts/new.tsx
import { Form } from "pracht";

export function Component() {
  return (
    <Form method="post" action="/api/posts">
      <label>
        Title
        <input type="text" name="title" required />
      </label>
      <label>
        Body
        <textarea name="body" required />
      </label>
      <button type="submit">Create Post</button>
    </Form>
  );
}

7. Use Vercel KV for Caching

pnpm add @vercel/kv
src/routes/dashboard.tsx
import { kv } from "@vercel/kv";
import { sql } from "@vercel/postgres";
import type { LoaderArgs } from "pracht";

export async function loader(_args: LoaderArgs) {
  // Check Redis cache first
  const cached = await kv.get("dashboard:stats");
  if (cached) return cached;

  // Expensive query
  const { rows } = await sql`
    SELECT COUNT(*) as total, MAX(created_at) as latest FROM posts
  `;
  const stats = rows[0];

  // Cache for 5 minutes
  await kv.set("dashboard:stats", stats, { ex: 300 });

  return stats;
}

8. Local Development

Pull your environment variables and run the dev server:

npx vercel env pull .env.local
pnpm dev
# Loaders and API routes connect to your Vercel Postgres and KV stores
[!INFO] Vercel Postgres and KV connect over the network even in development. Your local dev server talks to the same remote databases as production. Use a separate "preview" database for development if you want isolation.

9. Deploy

pracht build
npx vercel deploy --prebuilt

Or connect your Git repository in the Vercel dashboard for automatic deployments on push.

Tips

  • Use render: "ssr" for any route that reads from Postgres โ€” data changes per request.
  • The sql template tag from @vercel/postgres automatically parameterizes queries โ€” it's safe against SQL injection by default.
  • For ISG routes, pracht handles stale-while-revalidate automatically via the Vercel adapter. Use render: "isg" with a revalidate interval for pages that change infrequently.
  • Vercel Edge Functions have a 25MB size limit and a 30-second execution timeout. Keep loaders fast and move heavy work to background jobs.