Write server functions. Call them from the client. That's it.
Vite Server Actions brings the simplicity of server-side development to your Vite applications. Import server functions into your client code and call them directly - no API routes, no HTTP handling, no boilerplate.
// server/db.server.js
export async function getUsers() {
return await database.users.findAll();
}
// App.vue
import { getUsers } from "./server/db.server.js";
const users = await getUsers(); // Just call it!
- Zero API Boilerplate - No need to define routes, handle HTTP methods, or parse request bodies
- Type Safety - Full TypeScript support with proper type inference across client-server boundary
- Built-in Validation - Automatic request validation using Zod schemas
- Auto Documentation - OpenAPI spec and Swagger UI generated from your code
- Production Ready - Builds to a standard Node.js Express server
- Developer Experience - Hot reload, middleware support, and helpful error messages
- π Seamless Imports - Import server functions like any other module
- π‘οΈ Secure by Default - Server code never exposed to client
- β Request Validation - Attach Zod schemas for automatic validation
- π API Documentation - Auto-generated OpenAPI specs and Swagger UI
- π Middleware Support - Add authentication, logging, CORS, etc.
- π― Flexible Routing - Customize how file paths map to API endpoints
- π¦ Production Optimized - Builds to efficient Express server with all features
npm install vite-plugin-server-actions
// vite.config.js
import { defineConfig } from "vite";
import serverActions from "vite-plugin-server-actions";
export default defineConfig({
plugins: [
serverActions(), // That's it! Zero config needed
],
});
Any file ending with .server.js
becomes a server module:
// actions/todos.server.js
import { db } from "./database";
export async function getTodos(userId) {
// This runs on the server with full Node.js access
return await db.todos.findMany({ where: { userId } });
}
export async function addTodo(text, userId) {
return await db.todos.create({
data: { text, userId, completed: false },
});
}
// App.jsx
import { getTodos, addTodo } from './actions/todos.server.js'
function TodoApp({ userId }) {
const [todos, setTodos] = useState([])
useEffect(() => {
// Just call the server function!
getTodos(userId).then(setTodos)
}, [userId])
async function handleAdd(text) {
const newTodo = await addTodo(text, userId)
setTodos([...todos, newTodo])
}
return (
// Your UI here...
)
}
That's it! The plugin automatically:
- β Creates API endpoints for each function
- β Handles serialization/deserialization
- β Provides full TypeScript support
- β Works in development and production
// server/database.server.js
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export async function getUser(id) {
return await prisma.user.findUnique({
where: { id },
include: { profile: true },
});
}
export async function updateUser(id, data) {
return await prisma.user.update({
where: { id },
data,
});
}
// server/upload.server.js
import { writeFile } from "fs/promises";
import path from "path";
export async function uploadFile(filename, base64Data) {
const buffer = Buffer.from(base64Data, "base64");
const filepath = path.join(process.cwd(), "uploads", filename);
await writeFile(filepath, buffer);
return { success: true, path: `/uploads/${filename}` };
}
// server/weather.server.js
export async function getWeather(city) {
const response = await fetch(`https://api.weather.com/v1/current?city=${city}&key=${process.env.API_KEY}`);
return response.json();
}
// server/auth.server.js
import { z } from "zod";
import bcrypt from "bcrypt";
import { signJWT } from "./jwt";
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export async function login(credentials) {
// Validation happens automatically!
const user = await db.users.findByEmail(credentials.email);
if (!user || !(await bcrypt.compare(credentials.password, user.passwordHash))) {
throw new Error("Invalid credentials");
}
return { token: signJWT(user), user };
}
// Attach schema for automatic validation
login.schema = LoginSchema;
- Todo App with Svelte - Full-featured todo application with validation
- Todo App with Vue - Same todo app built with Vue 3
- Todo App with React - Same todo app built with React
- More examples coming soon for other frameworks
When you import a .server.js
file in your client code, Vite Server Actions:
- Intercepts the import - Replaces server module imports with client proxies
- Creates proxy functions - Each exported function becomes a client-side proxy
- Generates API endpoints - Maps each function to an HTTP endpoint
- Handles the transport - Serializes arguments and return values automatically
// What you write:
import { getUser } from "./user.server.js";
const user = await getUser(123);
// What runs in the browser:
const user = await fetch("/api/user/getUser", {
method: "POST",
body: JSON.stringify([123]),
}).then((r) => r.json());
- Development: Server functions run as Express middleware in Vite's dev server
- Production: Builds to a standalone Express server with all your functions
serverActions({
validation: {
enabled: true,
},
openAPI: {
enabled: true,
swaggerUI: true,
},
});
This gives you:
- Automatic request validation with Zod schemas
- OpenAPI spec at
/api/openapi.json
- Interactive docs at
/api/docs
serverActions({
middleware: [
// Add auth check to all server actions
(req, res, next) => {
if (!req.headers.authorization) {
return res.status(401).json({ error: "Unauthorized" });
}
next();
},
],
});
serverActions({
apiPrefix: "/rpc", // Change from /api to /rpc
routeTransform: (filePath, functionName) => {
// users.server.js -> /rpc/users.list
const module = filePath.replace(".server.js", "");
return `${module}.${functionName}`;
},
});
Option | Type | Default | Description |
---|---|---|---|
apiPrefix |
string |
"/api" |
URL prefix for all endpoints |
include |
string[] |
["**/*.server.js"] |
Files to process |
exclude |
string[] |
[] |
Files to ignore |
middleware |
Function[] |
[] |
Express middleware stack |
routeTransform |
Function |
See below | Customize URL generation |
validation |
Object |
{ enabled: false } |
Validation settings |
openAPI |
Object |
{ enabled: false } |
OpenAPI documentation settings |
import { pathUtils } from "vite-plugin-server-actions";
// Available presets:
pathUtils.createCleanRoute; // (default) auth.server.js β /api/auth/login
pathUtils.createLegacyRoute; // auth.server.js β /api/auth_server/login
pathUtils.createMinimalRoute; // auth.server.js β /api/auth.server/login
Option | Type | Default | Description |
---|---|---|---|
enabled |
boolean |
false |
Enable request validation |
adapter |
string |
"zod" |
Validation library adapter (only zod) |
Option | Type | Default | Description |
---|---|---|---|
enabled |
boolean |
false |
Enable OpenAPI generation |
swaggerUI |
boolean |
true |
Enable Swagger UI when OpenAPI is enabled |
info |
Object |
See below | OpenAPI specification info |
docsPath |
string |
"/api/docs" |
Path for Swagger UI |
specPath |
string |
"/api/openapi.json" |
Path for OpenAPI JSON spec |
Default info
object:
{
title: "Server Actions API",
version: "1.0.0",
description: "Auto-generated API documentation for Vite Server Actions"
}
Vite Server Actions includes a built-in logging middleware that provides detailed console output for debugging:
import serverActions, { middleware } from "vite-plugin-server-actions";
export default defineConfig({
plugins: [
serverActions({
middleware: middleware.logging,
}),
],
});
The logging middleware displays:
- π Action trigger details (module, function, endpoint)
- π¦ Formatted request body with syntax highlighting
- β Response time and data
- β Error responses with status codes
Example output:
[2024-01-21T10:30:45.123Z] π Server Action Triggered
ββ Module: src_actions_todo
ββ Function: addTodo
ββ Method: POST
ββ Endpoint: /api/src_actions_todo/addTodo
π¦ Request Body:
{
text: 'Buy groceries',
priority: 'high'
}
β
Response sent in 25ms
π€ Response data:
{
id: 1,
text: 'Buy groceries',
priority: 'high',
completed: false
}
ββββββββββββββββββββββββββββββββββββββββββββββββββ
You can add your own Express middleware for authentication, validation, etc:
import serverActions from "vite-plugin-server-actions";
// Authentication middleware
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: "Unauthorized" });
}
// Verify token...
next();
};
// CORS middleware
const corsMiddleware = (req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "POST");
res.header("Access-Control-Allow-Headers", "Content-Type");
next();
};
export default defineConfig({
plugins: [
serverActions({
middleware: [corsMiddleware, authMiddleware],
}),
],
});
Add validation to any server function by attaching a Zod schema. The plugin automatically validates requests and generates OpenAPI documentation.
// vite.config.js
serverActions({
validation: {
enabled: true,
},
openAPI: {
enabled: true,
swaggerUI: true,
},
});
// server/users.server.js
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
role: z.enum(["admin", "user"]).default("user"),
});
export async function createUser(data) {
// Input is pre-validated - this will never run with invalid data
const user = await db.users.create({ data });
// Send welcome email, etc...
return user;
}
// Just attach the schema!
createUser.schema = CreateUserSchema;
- Automatic Validation - Invalid requests return 400 with detailed errors
- Type Safety - Full TypeScript inference from your Zod schemas
- API Documentation - Browse and test your API at
/api/docs
- OpenAPI Spec - Machine-readable spec at
/api/openapi.json
// Handle arrays and complex inputs
const BulkUpdateSchema = z.array(
z.object({
id: z.number(),
status: z.enum(["active", "inactive"]),
}),
);
export async function bulkUpdateUsers(updates) {
// Type: { id: number, status: 'active' | 'inactive' }[]
return await db.users.updateMany(updates);
}
bulkUpdateUsers.schema = BulkUpdateSchema;
// Validate multiple parameters
export async function getDateRange(startDate, endDate) {
// Validate both parameters
return await db.analytics.query({ startDate, endDate });
}
getDateRange.schema = z.tuple([
z.string().datetime(), // startDate
z.string().datetime(), // endDate
]);
npm run build
This generates:
dist/server.js
- Your Express server with all endpointsdist/actions.js
- Bundled server functionsdist/openapi.json
- API specification (if enabled)- Client assets with proxy functions
node dist/server.js
Or with PM2:
pm2 start dist/server.js --name my-app
// Access environment variables in server functions
export async function sendEmail(to, subject, body) {
const apiKey = process.env.SENDGRID_API_KEY;
// ...
}
- Server files (
.server.js
) are never bundled into client code - Development builds include safety checks to prevent accidental imports
- Production builds completely separate server and client code
- Never trust client input - Always validate with Zod schemas
- Use middleware for auth - Add authentication checks globally
- Sanitize file operations - Be careful with file paths from clients
- Limit exposed functions - Only export what clients need
- Use environment variables - Keep secrets out of code
// β Dangerous - allows arbitrary file access
export async function readFile(path) {
return await fs.readFile(path, "utf-8");
}
// β
Safe - validates and restricts access
import { z } from "zod";
const FileSchema = z.enum(["report.pdf", "summary.txt"]);
export async function readAllowedFile(filename) {
const safePath = path.join(SAFE_DIR, filename);
return await fs.readFile(safePath, "utf-8");
}
readAllowedFile.schema = FileSchema;
Vite Server Actions has first-class TypeScript support with automatic type inference:
// server/users.server.ts
export async function getUser(id: number) {
return await db.users.findUnique({ where: { id } });
}
// App.tsx - Full type inference!
import { getUser } from "./server/users.server";
const user = await getUser(123); // Type: User | null
import { z } from "zod";
const schema = z.object({
name: z.string(),
age: z.number(),
});
export async function createUser(data: z.infer<typeof schema>) {
return await db.users.create({ data });
}
createUser.schema = schema;
Server errors are automatically caught and returned with proper HTTP status codes:
// server/api.server.js
export async function riskyOperation() {
throw new Error("Something went wrong");
}
// Client receives:
// Status: 500
// Body: { error: "Internal server error", details: "Something went wrong" }
export async function authenticate(token) {
if (!token) {
const error = new Error("No token provided");
error.status = 401;
throw error;
}
// ...
}
// server/auth.server.js
export async function withAuth(handler) {
return async (...args) => {
const token = args[args.length - 1]; // Pass token as last arg
const user = await verifyToken(token);
if (!user) throw new Error("Unauthorized");
return handler(...args.slice(0, -1), user);
};
}
// server/protected.server.js
import { withAuth } from "./auth.server";
export const getSecretData = withAuth(async (user) => {
return await db.secrets.findMany({ userId: user.id });
});
const cache = new Map();
export async function getExpensiveData(key) {
if (cache.has(key)) {
return cache.get(key);
}
const data = await expensiveOperation(key);
cache.set(key, data);
// Clear after 5 minutes
setTimeout(() => cache.delete(key), 5 * 60 * 1000);
return data;
}
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
See CONTRIBUTING.md for development setup and guidelines.
This project is MIT licensed.
Made with β€οΈ by Helge Sverre