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.

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: staticbuild: pnpm buildout: dist
kind: staticbuild: pnpm buildout: 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
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"}}
{"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()],});
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: dynamicbuild: pnpm buildout: distassets:binding: ASSETSnot_found_handling: single-page-applicationrun_worker_first:- /api/*db:DB: default
kind: dynamicbuild: pnpm buildout: distassets:binding: ASSETSnot_found_handling: single-page-applicationrun_worker_first:- /api/*db:DB: default
not_found_handling: single-page-applicationfalls back toindex.htmlso client-side routes resolve.run_worker_firstsends/api/*to the worker before the asset layer; prefix a pattern with!to exclude it.dbattaches a SQLite database, typed asenv.DBafterpnpm 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;
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
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
- Deploy a Vite app free
- Read the Vite docs and the
ploy vitecommand reference - Next: Deploy TanStack Start on Ploy