π A Vite plugin for adding Node.js API routes to your Vite + Vue project. JSON-only, single-port backend and frontend, similar to Next.js API routes but for Vite.
- File-based routing -
/api/hello.jsβ/api/hello - Single-port deployment - Dev and production on one port
- JSON-only API - Auto-parse request body and query params
- Hot reload - API changes auto-reload in dev mode
- Security built-in - Path traversal protection, body limits, timeouts
- CORS support - Optional CORS headers configuration
- Production-ready - ESBuild bundling with minification
- TypeScript support - Full TypeScript definitions included
- Zero config - Works out of the box with sensible defaults
npm install vite-node-api// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import viteNodeApi from 'vite-node-api'
export default defineConfig({
plugins: [
vue(),
viteNodeApi({
apiDir: 'server/api', // optional, default: 'server/api'
port: 4173, // optional, default: 4173
cors: true // optional, default: false
})
]
})// server/api/hello.js
export default async (req, res) => {
return {
message: 'Hello from API!',
method: req.method,
query: req.query
}
}npm run devVisit http://localhost:5173/api/hello β {"message":"Hello from API!","method":"GET","query":{}}
npm run buildThis creates:
dist/client/- Frontend filesdist/server/- Bundled API routes + runtime
npm run buildThis creates:
dist/
βββ client/ # Frontend static files (HTML, CSS, JS)
βββ server/ # Backend bundled files
β βββ entry.mjs # Production runtime (standalone)
β βββ server/ # API routes (preserves folder structure)
β βββ api/
βββ .env # Auto-copied from .env.production
Key Features:
- β
Standalone - All dependencies bundled, no
node_modulesneeded - β
Environment variables -
.env.productionauto-copied todist/.env - β
Folder structure preserved -
server/api/stays asdist/server/server/api/
# Simple - just run the entry file
node dist/server/entry.mjsOR with custom environment variables:
# Override .env values
PORT=3000 node dist/server/entry.mjsBoth frontend and API run on the same port (default: 4173).
Upload only the dist/ folder:
# 1. Build locally
npm run build
# 2. Upload dist/ to your server
scp -r dist/ user@server:/var/www/myapp/
# 3. On server, run it
ssh user@server
cd /var/www/myapp/dist
node server/entry.mjsNo npm install needed - everything is bundled!
// server/api/users/[id].js
export default async (req, res) => {
const { id } = req.params
const user = { id, name: 'Alice', email: 'alice@example.com' }
return user
}Request: GET /api/users/123
Response: {"id":"123","name":"Alice","email":"alice@example.com"}
// server/api/search.js
export default async (req, res) => {
const { q, limit = 10 } = req.query
return {
query: q,
limit: parseInt(limit),
results: []
}
}Request: GET /api/search?q=hello&limit=5
Response: {"query":"hello","limit":5,"results":[]}
// server/api/users/create.js
export default async (req, res) => {
const { name, email } = req.body
// Validate input
if (!name || !email) {
res.statusCode = 400
return { error: 'Name and email required' }
}
// Simulate database insert
const newUser = {
id: Date.now(),
name,
email,
createdAt: new Date().toISOString()
}
return newUser
}Request:
curl -X POST http://localhost:5173/api/users/create \
-H "Content-Type: application/json" \
-d '{"name":"Bob","email":"bob@example.com"}'Response: {"id":1704067200000,"name":"Bob","email":"bob@example.com","createdAt":"2025-01-01T00:00:00.000Z"}
server/api/
βββ hello.js β /api/hello
βββ users/
β βββ list.js β /api/users/list
β βββ create.js β /api/users/create
β βββ [id].js β /api/users/[id]
βββ posts/
βββ index.js β /api/posts/index
βββ [slug].js β /api/posts/[slug]
// server/api/custom.js
export default async (req, res) => {
// Set custom headers
res.setHeader('X-Custom-Header', 'value')
// Set status code
res.statusCode = 201
// Manual response (won't auto-JSON encode)
res.setHeader('Content-Type', 'text/plain')
res.end('Custom response')
// No return needed when manually writing response
}// server/api/posts/list.js
export default async (req, res) => {
// Simulate async database query
await new Promise(resolve => setTimeout(resolve, 100))
const posts = [
{ id: 1, title: 'First Post' },
{ id: 2, title: 'Second Post' }
]
return posts
}viteNodeApi({
// Directory containing API route files
// Default: 'server/api'
apiDir: 'server/api',
// Port for production runtime
// Default: 4173 (Vite preview default)
port: 3000,
// Maximum request body size in bytes
// Default: 1000000 (1MB)
bodyLimit: 5_000_000,
// Request timeout in milliseconds
// Default: 30000 (30s)
timeout: 60000,
// Enable CORS headers
// Default: false
cors: true,
// Or configure CORS with custom origin
cors: {
origin: 'https://example.com'
}
})vite-node-api automatically handles environment variables for both development and production:
Development (.env.development):
# Loaded by Vite dev server
VITE_APP_API_KEY=dev-key-123
DATABASE_URL=postgresql://localhost/mydbProduction (.env.production):
# Auto-copied to dist/.env during build
VITE_APP_API_KEY=prod-key-456
DATABASE_URL=postgresql://production/mydbUsing in API routes:
// server/api/data.js
export default async (req, res) => {
const apiKey = process.env.VITE_APP_API_KEY
const dbUrl = process.env.DATABASE_URL
// Your logic here
return { status: 'ok' }
}Key Points:
- β
Development: Vite automatically loads
.env.development - β
Production:
.env.productionis auto-copied todist/.envduring build - β
Runtime: Production server auto-loads
dist/.envon startup - β
Override: Can override env vars when running:
PORT=3000 node dist/server/entry.mjs
API route handlers receive an enhanced request object:
{
method: string // HTTP method: GET, POST, PUT, PATCH, DELETE
url: string // Full request URL
headers: IncomingHttpHeaders
body?: any // Parsed JSON body (POST/PUT/PATCH only)
query: Record<string, string> // Parsed query parameters (?key=value)
params: Record<string, string> // Dynamic route parameters ([id])
// ... all standard Node.js IncomingMessage properties
}Standard Node.js ServerResponse with helper methods:
export default async (req, res) => {
// Set status code
res.statusCode = 404
// Set headers
res.setHeader('Content-Type', 'application/json')
// Return data (auto-JSON encoded)
return { error: 'Not found' }
// Or manually send response
res.end(JSON.stringify({ error: 'Not found' }))
}Built-in protection against path traversal attacks:
β
/api/users/list β server/api/users/list.js
β /api/../../../etc/passwd β 403 Forbidden
Default 1MB limit for request bodies (configurable):
viteNodeApi({
bodyLimit: 5_000_000 // 5MB
})Requests exceeding the limit return 413 Request Entity Too Large.
Default 30s timeout per request (configurable):
viteNodeApi({
timeout: 60000 // 60s
})Requests exceeding the timeout return 408 Request Timeout.
// Simple CORS (allow all origins)
viteNodeApi({
cors: true
})
// Custom origin
viteNodeApi({
cors: {
origin: 'https://example.com'
}
})my-vite-app/
βββ src/
β βββ main.js # Frontend entry
β βββ App.vue
βββ server/
β βββ api/
β βββ hello.js # API routes
β βββ users/
β β βββ list.js
β βββ posts/
β βββ [id].js
βββ vite.config.js # Vite config with plugin
βββ package.json
After build:
dist/
βββ client/ # Frontend files (served by entry.mjs)
β βββ index.html
β βββ assets/
β βββ ...
βββ server/ # Backend files
βββ entry.mjs # Production runtime server
βββ api/ # Bundled API routes
βββ hello.js
βββ users/
βββ list.js
# Build
npm run build
# Run production server
node dist/server/entry.mjsFROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY dist ./dist
EXPOSE 4173
CMD ["node", "dist/server/entry.mjs"]# Override port
PORT=3000 node dist/server/entry.mjs
# Or use plugin config env variable
VITE_NODE_API_PORT=3000 node dist/server/entry.mjs
# Override timeout
VITE_NODE_API_TIMEOUT=60000 node dist/server/entry.mjsPM2:
pm2 start dist/server/entry.mjs --name my-apiSystemd:
[Unit]
Description=Vite Node API Server
[Service]
ExecStart=/usr/bin/node /path/to/dist/server/entry.mjs
Restart=always
Environment=PORT=4173
[Install]
WantedBy=multi-user.targetnpm testTest coverage includes:
- Plugin configuration
- API routing
- Security (path traversal, body limits)
- Build process
- Error handling
See test/ directory for examples.
Run benchmarks with autocannon:
npm run benchPerformance with fast-json-stringify and fast-json-parse optimization (100 concurrent connections, 10s duration):
| Endpoint | Req/sec | Latency | Throughput |
|---|---|---|---|
| Simple GET | 3,331 | 29.53ms | ~720 KB/s |
| GET with Query | 2,281 | 43.20ms | ~500 KB/s |
| Complex JSON | 2,965 | 33.25ms | ~1.0 MB/s |
| POST with Body | 2,146 | 46.07ms | ~480 KB/s |
Key optimizations:
- β‘ +17% faster on simple GET requests
- β‘ +7% faster on POST with JSON body parsing
- π Pre-compiled JSON schemas for error responses
- π¦ Smart fallback to native JSON for complex objects
Results may vary based on hardware and system load. Run
npm run benchon your system for accurate measurements.
See benchmark/ directory for more details.
type ApiHandler = (
req: ApiRequest,
res: ApiResponse
) => Promise<any> | anyReturn value is automatically JSON-encoded. If you manually send a response, don't return anything.
Errors are automatically caught and returned as JSON:
export default async (req, res) => {
throw new Error('Something went wrong')
// Returns: {"error":"Something went wrong"}
// Status: 500
}// Success
res.statusCode = 200 // OK (default)
res.statusCode = 201 // Created
res.statusCode = 204 // No Content
// Client Errors
res.statusCode = 400 // Bad Request
res.statusCode = 401 // Unauthorized
res.statusCode = 403 // Forbidden
res.statusCode = 404 // Not Found
// Server Errors
res.statusCode = 500 // Internal Server ErrorFor more detailed examples and use cases, please check the examples folder:
- Configuration examples (basic, CORS, custom options)
- API route examples (GET, POST, query parameters, validation)
- Complete documentation and usage guides
MIT Β© 2025