Deploy a React (Vite) app on Ploy, with or without a backend

Deploy a Vite React app on Ploy in two ways: as a static site with three lines of ploy.yaml, or as a SPA plus a serverless worker with API routes and a SQLite database using @meetploy/vite.

Abstract indigo glow on a dark background representing a Vite React deployment on Ploy

A Vite React app starts as static files, and static hosting is a solved problem. Then the app needs an /api/ route, a place to store data, and a secret that cannot ship to the browser, and the "static" project sprouts a second repository, a second host, and CORS headers.

Ploy handles both stages in one project. You can deploy a React Vite app on Ploy as a pure static site, or add a serverless worker next to it that serves /api/*, talks to a SQLite database, and deploys from the same push. This guide covers both.

Option 1: deploy a static Vite app

Push the repository to GitHub, connect it in the Ploy dashboard, and add a ploy.yaml:

kind: static
build: pnpm build
out: dist

That is the entire configuration. Every push builds and deploys; the production branch gets your production URL, and every other branch gets a preview URL. Commits containing [skip deploy] are ignored, and you can set ignored branch patterns under deploy: in ploy.yaml or in the project settings.

If your app lives in a monorepo, point the project at the right package and Ploy builds from there. See configuration.

Option 2: deploy a Vite SPA with a worker

When the app needs a backend, keep the SPA and add a worker entry at worker/index.ts. Ploy serves the built assets and routes matching paths to the worker first.

Install the Vite plugin and point the scripts at Ploy:

pnpm add -D @meetploy/vite @meetploy/cli
{
"scripts": {
"dev": "ploy vite dev",
"build": "tsc --noEmit && ploy vite build",
"types": "ploy types -o env.d.ts"
}
}
import { ploy } from "@meetploy/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), ...ploy()],
});

ploy() returns an array of plugins, so spread it. Then describe assets and bindings in ploy.yaml:

kind: dynamic
build: pnpm build
out: dist
assets:
binding: ASSETS
not_found_handling: single-page-application
run_worker_first:
- /api/*
db:
DB: default
  • not_found_handling: single-page-application falls back to index.html so client-side routes resolve.
  • run_worker_first sends /api/* to the worker before the asset layer; prefix a pattern with ! to exclude it.
  • db attaches a SQLite database, typed as env.DB after pnpm types.

The worker

interface Todo {
id: number;
title: string;
done: number;
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/api/todos" && request.method === "GET") {
const { results } = await env.DB.prepare(
"SELECT * FROM todos ORDER BY id",
).all<Todo>();
return Response.json({ todos: results });
}
if (url.pathname === "/api/todos" && request.method === "POST") {
const { title } = (await request.json()) as { title: string };
await env.DB.prepare("INSERT INTO todos (title, done) VALUES (?, 0)")
.bind(title)
.run();
return Response.json({ ok: true }, { status: 201 });
}
return new Response("Not Found", { status: 404 });
},
} satisfies Ploy;

The todos table comes from a .sql file in migrations/. Ploy applies it on the first production deploy and ploy dev applies it locally.

The React side calls fetch("/api/todos") with no CORS and no second domain, because the worker and the assets are the same deployment.

Local development

pnpm dev

ploy vite dev starts the Vite dev server with the bindings from ploy.yaml wired in, and ploy dev additionally starts the local dashboard where you can browse the database. Secrets go in .env locally and in the project settings in production; the worker reads them from env.vars, and they never reach the browser bundle.

Static or worker: which one?

You need Pick
Marketing site, docs, portfolio kind: static
API routes without a separate host kind: dynamic + worker
A database, queue, or cron kind: dynamic + bindings
Secrets that must stay server-side kind: dynamic + env.vars
Server rendering with file routing TanStack Start

You can start static and switch to a worker later; the project, URL, and deploy flow stay the same.

Frequently Asked Questions

Does Ploy support Vue, Svelte, Astro, and other Vite frameworks?

Static builds from any framework deploy with kind: static. The @meetploy/vite worker integration is used for React SPAs and TanStack Start; other frameworks can still ship a worker at worker/index.ts.

How do preview deployments work for a Vite app?

Each branch gets its own URL on every push, and branch deletions clean up their previews. Migrations only run on production deploys from the default branch, so a preview cannot change your schema.

Is a Vite worker the same as a Ploy Start backend?

A plain worker exports a fetch handler. If you want typed routes, Zod validation, and OpenAPI docs, build the worker with @meetploy/start; see Ploy Start: build a serverless backend.

What does it cost?

The hosted Free plan includes up to 3 repositories. Pro is $10 per month with unlimited repositories and custom domains. Self-hosting is free.

Get started