Back to home
Development

How I Built a Full-Stack App for $0 in 2026 (Bye Bye AWS & Vercel)

2026-03-1822 min read

I got tired of watching small side projects burn a few dollars a month on Vercel or AWS before they had any users. So I rebuilt one of them from scratch on a stack that’s genuinely free: Cloudflare Workers for the app and API, Cloudflare D1 for the database, and Vite + React for the front end. No Vercel, no AWS, no credit card—and it’s been running at $0 for months. This is the exact path I followed: project setup, database schema and migrations, API routes, and deployment, with real commands and the limits you’ll hit so nothing here is a surprise.

Everything in this guide uses current Cloudflare free-tier limits (as of 2025–2026): 100,000 Worker requests per day, D1 with 5 GB total storage (500 MB per database, 10 databases per account), and unlimited static asset requests on Workers with the Vite plugin. I’ll call out where you’d need to upgrade if you outgrow the free tier.

Why I Chose Cloudflare Workers + D1 (and What’s Actually Free)

Cloud or edge infrastructure concept

I wanted one place that could serve the front end, run the API, and host the database without splitting the stack across three vendors. Cloudflare Workers runs your code at the edge; with the Cloudflare Vite plugin, you get a single Worker that serves your React app and your API routes. D1 is Cloudflare’s serverless SQL database (SQLite under the hood), so you get real SQL and migrations without managing a server or paying for a hosted Postgres/MySQL instance.

What’s free (and what isn’t):

  • Workers: 100,000 requests per day on the free plan. Each API call and each non-cached page load counts. Static assets (JS, CSS, images) served from the Worker’s asset binding don’t count toward that limit in the same way in many setups—check Cloudflare’s latest docs for “Requests” definition. For a small app or side project, 100k/day is usually enough.
  • D1: Up to 10 databases, 500 MB per database, 5 GB total storage per account. No charge for reads/writes within those limits. Enough for tens of thousands of rows for typical app tables.
  • No credit card required for the free tier. You only pay if you explicitly enable Workers Paid or use products that bill (e.g. R2, Images).

What you’re not using: Vercel (so no serverless function invocations or edge config), AWS (no Lambda, no RDS), and no separate “free Postgres” like Neon or Supabase for this build—D1 is the only database so the whole stack stays inside Cloudflare and stays $0. You can add Supabase or Neon later if you need Postgres or more storage; for a first version, D1 was enough for me.

Step 1: Project Setup (Vite + React + Cloudflare Worker)

Code editor with React and config files

I started from a Vite + React + TypeScript app and wired in the Cloudflare plugin and a Worker entry so the same deploy serves the SPA and the API. Here’s the sequence I used.

1. Create the app and install Cloudflare tooling

npm create vite@latest my-zero-app -- --template react-ts
cd my-zero-app
npm install
npm i -D @cloudflare/vite-plugin wrangler @cloudflare/workers-types

2. Add the Cloudflare plugin to Vite

In vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { cloudflare } from "@cloudflare/vite-plugin";

export default defineConfig({
  plugins: [react(), cloudflare()],
});

3. Create the Worker config

Add wrangler.jsonc in the project root (use a recent compatibility_date; I used 2026-03-01):

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "my-zero-app",
  "compatibility_date": "2026-03-01",
  "main": "./worker/index.ts",
  "assets": {
    "directory": "./dist/client",
    "not_found_handling": "single-page-application",
    "run_worker_first": ["/api/*"]
  }
}
  • main is the Worker entry that will handle /api/* and pass everything else to the SPA.
  • run_worker_first for /api/* ensures API requests hit your Worker logic instead of the static assets.

4. Add the Worker entry

Create worker/index.ts:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname.startsWith("/api/")) {
      return handleApi(request, env);
    }
    return new Response(null, { status: 404 });
  },
} satisfies ExportedHandler<Env>;

async function handleApi(request: Request, env: Env): Promise<Response> {
  const url = new URL(request.url);
  if (url.pathname === "/api/health" && request.method === "GET") {
    return Response.json({ ok: true });
  }
  return Response.json({ error: "Not found" }, { status: 404 });
}

For now the API only has a health check; we’ll add D1 and real routes in Step 2. Define an empty Env in the same file so TypeScript is happy (you’ll add the D1 binding in Step 2):

export interface Env {}

5. TypeScript for the Worker

Create or update tsconfig.worker.json so the Worker uses Cloudflare types:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "types": ["@cloudflare/workers-types/2024-11-01", "vite/client"],
    "strict": true,
    "noEmit": true
  },
  "include": ["worker"]
}

6. Run and build

npm run dev

The Vite dev server runs with the Cloudflare plugin; your Worker runs in the same process. Hit http://localhost:5173/api/health and you should get {"ok":true}. Then:

npm run build
npm exec wrangler deploy

You’ll be prompted to log in to Cloudflare if you haven’t. After deploy, your app and /api/health are live on a *.workers.dev URL. No database yet—next step is D1.

Step 2: Add D1 and Define the Schema

Database schema or table structure

I used D1 for persistence and Drizzle ORM for schema and queries so I could keep migrations and types in one place.

1. Create a D1 database

npx wrangler d1 create my_zero_db

Wrangler prints something like:

✅ Successfully created DB 'my_zero_db'
[[d1_databases]]
binding = "DB"
database_name = "my_zero_db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

2. Wire the binding into wrangler.jsonc

Add the d1_databases block (use your real database_id):

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "my-zero-app",
  "compatibility_date": "2026-03-01",
  "main": "./worker/index.ts",
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "my_zero_db",
      "database_id": "YOUR_DATABASE_ID_HERE"
    }
  ],
  "assets": {
    "directory": "./dist/client",
    "not_found_handling": "single-page-application",
    "run_worker_first": ["/api/*"]
  }
}

3. Install Drizzle and define the schema

npm i drizzle-orm
npm i -D drizzle-kit

Create worker/schema.ts:

import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";

export const items = sqliteTable("items", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull(),
  createdAt: integer("created_at", { mode: "timestamp" })
    .$defaultFn(() => new Date())
    .notNull(),
});

4. Generate and apply migrations

Drizzle can generate SQL migrations from the schema. Configure drizzle.config.ts at the project root for D1 (dialect sqlite, driver d1-http or use the local D1 path). For a minimal path I used Wrangler’s native migrations: create a folder and the first migration by hand.

Create migrations/0000_initial.sql:

CREATE TABLE IF NOT EXISTS items (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  created_at INTEGER NOT NULL
);

Apply it locally (for wrangler dev) and remotely (for production):

npx wrangler d1 migrations apply my_zero_db
npx wrangler d1 migrations apply my_zero_db --remote

Use the database_name from your config (my_zero_db), not the binding name. After this, your D1 database has an items table.

5. Use D1 and Drizzle in the Worker

In worker/index.ts, instantiate Drizzle with env.DB and add a simple API that reads and writes items. Update your Env interface and add the desc import:

import { drizzle } from "drizzle-orm/d1";
import { desc } from "drizzle-orm";
import { items } from "./schema";

export interface Env {
  DB: D1Database;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname.startsWith("/api/")) {
      return handleApi(request, env);
    }
    return new Response(null, { status: 404 });
  },
} satisfies ExportedHandler<Env>;

async function handleApi(request: Request, env: Env): Promise<Response> {
  const db = drizzle(env.DB);
  const url = new URL(request.url);

  if (url.pathname === "/api/health" && request.method === "GET") {
    return Response.json({ ok: true });
  }

  if (url.pathname === "/api/items" && request.method === "GET") {
    const rows = await db.select().from(items).orderBy(desc(items.createdAt));
    return Response.json(rows);
  }

  if (url.pathname === "/api/items" && request.method === "POST") {
    const body = await request.json() as { title?: string };
    if (!body?.title || typeof body.title !== "string") {
      return Response.json({ error: "title required" }, { status: 400 });
    }
    const result = await db.insert(items).values({ title: body.title }).returning();
    return Response.json(result[0], { status: 201 });
  }

  return Response.json({ error: "Not found" }, { status: 404 });
}

Rebuild and deploy; you now have a full-stack app with a real database at $0.

Step 3: Connect the Front End and Harden the API

React app calling API

The React app runs on the same origin as the API, so you can call fetch("/api/items") without CORS. I added a simple list + form that loads items on mount and POSTs new ones, with basic error handling and loading states. Keeping the UI small and the API surface clear made it easy to reason about and stayed within good React practices: one component for the list, one for the form, and state lifted only as much as needed.

CORS: For a same-origin SPA on Workers you often don’t need CORS. If you later add a separate front-end domain, set Access-Control-Allow-Origin (and optionally methods/headers) on API responses. For this $0 setup I didn’t need it.

Validation: I kept server-side checks minimal but strict: for POST /api/items, require a non-empty title string and reject anything else with 400. No ORM-level validation in this example—just enough to avoid bad data.

Rate limiting: The free Worker plan doesn’t include built-in rate limiting. For a small personal or side project I didn’t add it; if you open the app to the public, consider Cloudflare’s rate limiting or a simple in-memory (per-Worker) throttle so one client can’t burn your 100k daily requests.

Step 4: Deploy and Keep It at $0

Deploy or CI pipeline concept

Deploy from the CLI

npm run build
npm exec wrangler deploy

The first time you run wrangler deploy, you’ll log in (browser or API token). The build output includes the Worker and the static assets; Wrangler uploads both. Your app is live at https://my-zero-app.<your-subdomain>.workers.dev. You can add a custom domain in the Cloudflare dashboard (still $0 for the domain itself if you own it).

Git-based deploys (optional)
You can connect a GitHub repo to Cloudflare Workers in the dashboard so every push to main triggers a build and deploy. The build command is npm run build; the deployment uses the built artifacts and the same wrangler config. That way you get “push to deploy” without Vercel or AWS.

Staying within free limits

  • Workers: 100,000 requests/day. Monitor in the Cloudflare dashboard under Workers & Pages → your Worker → Metrics. If you approach the limit, consider caching static assets or adding a small cache for read-heavy API routes.
  • D1: 500 MB per database, 5 GB total. For an app with a few tables and thousands of rows, you’re well under. If you grow, check D1’s paid tier or consider Supabase/Neon for Postgres.
  • No surprise bills: As long as you don’t enable Workers Paid or add billable products, your account stays at $0. I’ve left this app running for months with no charges.

What I’d Do Again (and What I’d Change)

Do again:

  • Single stack: One deploy, one Worker, one D1 DB. No splitting front end on Vercel and API on AWS.
  • Drizzle + D1: Type-safe queries and a clear migration path. Wrangler’s d1 migrations apply (local + remote) kept dev and prod in sync.
  • Vite + Cloudflare plugin: Fast dev experience and a production build that matches the Worker runtime.
  • Starting with a tiny API: One table and two routes (GET/POST items) made it easy to validate the pipeline before adding more features.

Change next time:

  • Auth: This guide doesn’t include auth. For a real multi-user app I’d add sessions (e.g. D1-backed sessions or a provider like Supabase Auth or Clerk free tier) and protect API routes.
  • Migrations in CI: I’d run wrangler d1 migrations apply my_zero_db --remote in the deployment pipeline so every deploy ensures the remote DB schema is up to date.
  • Secrets: For API keys or third-party tokens, use wrangler secret put <NAME> and read them in the Worker as env.<NAME>. Never commit secrets.

FAQ

Q: Can you really build a full-stack app for $0 in 2026 without AWS or Vercel?
Yes. Using Cloudflare Workers (front end + API via the Vite plugin), Cloudflare D1 (SQL database), and the free tier of both, you can run a full-stack app at $0. Limits include 100,000 Worker requests per day and 5 GB total D1 storage (500 MB per database). No credit card is required for the free plan.

Q: What are the main limits of Cloudflare Workers and D1 on the free tier?
Workers: 100,000 requests per day; 10 ms CPU time per request on free; 128 MB memory. D1: 10 databases per account, 500 MB per database, 5 GB total storage; 50 queries per Worker invocation. For a small or medium side project these are usually enough. See Cloudflare’s official Limits docs for the latest numbers.

Q: Do I need a credit card to use Cloudflare Workers and D1?
No. The free tier does not require a credit card. You only pay if you enable Workers Paid or use other billable Cloudflare products (e.g. R2, Images). Your app can stay at $0 as long as you stay within the free limits.

Q: How do I run database migrations for D1 in production?
After defining your schema and migration SQL (e.g. in a migrations/ folder), run npx wrangler d1 migrations apply <database_name> --remote to apply pending migrations to the remote D1 database. Use the same command in your CI/CD pipeline so every deploy can bring the production schema up to date.

Q: Can I use Next.js or another framework instead of Vite + React?
You can use Next.js with Cloudflare by using the @cloudflare/next-on-pages adapter or static export and deploying to Pages; the setup and limits differ from the Workers + Vite approach. For a single Worker that serves both the SPA and the API, Vite + React (or Vue, Svelte) with the Cloudflare Vite plugin is the path documented here and is fully supported as of 2025–2026.

Related keywords

  • full-stack app for free 2026
  • Cloudflare Workers D1 tutorial
  • build app without AWS or Vercel
  • Drizzle ORM D1 Cloudflare
  • serverless SQLite free tier
  • Cloudflare Vite plugin React
  • deploy full-stack app $0
  • Cloudflare Workers free tier limits

I didn’t want to depend on Vercel or AWS for a small project that might never make money. Moving to Cloudflare Workers and D1 let me ship a real full-stack app—React front end, JSON API, and a SQL database—with zero monthly cost and a single deploy. The setup in this guide is the one I used: Vite + React, a Worker that handles /api/*, D1 with Drizzle, and Wrangler for migrations and deploy. If you follow the same steps and keep an eye on the free-tier limits, you can run your own full-stack app for $0 in 2026 and say bye to AWS and Vercel for this project.