Build Lightning-Fast Next.js APIs with Elysia - Complete CRUD Guide
Master building ultra-fast, type-safe Next.js APIs using Elysia framework with Prisma ORM and React Query. Learn how to create production-ready CRUD operations with end-to-end type safety, achieve 2.5M+ requests/sec performance, and deploy to Vercel with WinterCG compliance.
Building a CRUD API with Elysia in Next.js
Complete guide for creating a type-safe, high-performance Product CRUD API using Elysia, Prisma ORM, and React Query.
Table of Contents
- Prerequisites
- Project Setup
- Prisma Configuration
- Elysia API Setup
- React Query Integration
- Usage Examples
- Deployment
Prerequisites
- Node.js 18+ or Bun runtime
- Basic knowledge of Next.js, TypeScript, and React
- Understanding of REST APIs
Project Setup
1. Initialize Next.js Project
pnpm create next-app@latest elysia-crud-app
cd elysia-crud-app
Choose the following options:
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: Yes
- App Router: Yes
- src/ directory: Yes
2. Install Dependencies
# Core dependencies
npm install elysia @elysiajs/eden
npm install prisma @prisma/client
npm install @tanstack/react-query
# Development dependencies
npm install -D prisma3. Initialize Prisma
pnpm dlx prisma init
Prisma Configuration
1. Configure Database (.env)
DATABASE_URL="postgresql://user:password@localhost:5432/products_db"
# For development, you can use SQLite:
# DATABASE_URL="file:./dev.db"2. Define Schema (prisma/schema.prisma)
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Product {
id String @id @default(cuid())
name String
description String?
price Float
stock Int @default(0)
category String
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
@@index([createdAt])
}3. Generate Prisma Client & Run Migrations
pnpm dlx prisma generate
npx prisma migrate dev --name init
4. Create Prisma Client Instance (src/lib/prisma.ts)
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;Elysia API Setup
1. Create Elysia Server (src/app/api/[...slugs]/route.ts)
This is the central endpoint that handles all API requests:
import { Elysia, t } from "elysia";
import { prisma } from "@/lib/prisma";
const app = new Elysia({ prefix: "/api" })
// Get all products
.get("/products", async () => {
const products = await prisma.product.findMany({
orderBy: { createdAt: "desc" },
});
return products;
})
// Get single product by ID
.get(
"/products/:id",
async ({ params }) => {
const product = await prisma.product.findUnique({
where: { id: params.id },
});
if (!product) {
throw new Error("Product not found");
}
return product;
},
{
params: t.Object({
id: t.String(),
}),
}
)
// Create product
.post(
"/products",
async ({ body }) => {
const product = await prisma.product.create({
data: body,
});
return product;
},
{
body: t.Object({
name: t.String({ minLength: 1 }),
description: t.Optional(t.String()),
price: t.Number({ minimum: 0 }),
stock: t.Number({ minimum: 0 }),
category: t.String({ minLength: 1 }),
imageUrl: t.Optional(t.String()),
}),
}
)
// Update product
.put(
"/products/:id",
async ({ params, body }) => {
const product = await prisma.product.update({
where: { id: params.id },
data: body,
});
return product;
},
{
params: t.Object({
id: t.String(),
}),
body: t.Object({
name: t.Optional(t.String({ minLength: 1 })),
description: t.Optional(t.String()),
price: t.Optional(t.Number({ minimum: 0 })),
stock: t.Optional(t.Number({ minimum: 0 })),
category: t.Optional(t.String({ minLength: 1 })),
imageUrl: t.Optional(t.String()),
}),
}
)
// Delete product
.delete(
"/products/:id",
async ({ params }) => {
await prisma.product.delete({
where: { id: params.id },
});
return { success: true, message: "Product deleted" };
},
{
params: t.Object({
id: t.String(),
}),
}
)
// Get products by category
.get(
"/products/category/:category",
async ({ params }) => {
const products = await prisma.product.findMany({
where: { category: params.category },
orderBy: { createdAt: "desc" },
});
return products;
},
{
params: t.Object({
category: t.String(),
}),
}
);
// Export type for client-side usage
export type App = typeof app;
// Export HTTP methods for Next.js
export const GET = app.handle;
export const POST = app.handle;
export const PUT = app.handle;
export const DELETE = app.handle;2. Error Handling (Enhanced Version)
For production, add proper error handling:
import { Elysia, t } from "elysia";
import { prisma } from "@/lib/prisma";
const app = new Elysia({ prefix: "/api" }).onError(({ code, error, set }) => {
if (code === "NOT_FOUND") {
set.status = 404;
return { error: "Resource not found" };
}
if (code === "VALIDATION") {
set.status = 400;
return { error: "Validation failed", details: error.message };
}
set.status = 500;
return { error: "Internal server error" };
});
// ... rest of your routesReact Query Integration
1. Create React Query Provider (src/providers/query-provider.tsx)
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
}))
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}2. Wrap App with Provider (src/app/layout.tsx)
import { QueryProvider } from '@/providers/query-provider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<QueryProvider>
{children}
</QueryProvider>
</body>
</html>
)
}3. Create Type-Safe API Client (src/lib/api.ts)
import { edenTreaty } from "@elysiajs/eden";
import type { App } from "@/app/api/[...slugs]/route";
export const api = edenTreaty<App>("http://localhost:3000");For production, use environment variables:
export const api = edenTreaty<App>(
process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000"
);4. Create Product Hooks (src/hooks/use-products.ts)
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
// Get all products
export function useProducts() {
return useQuery({
queryKey: ["products"],
queryFn: async () => {
const { data } = await api.api.products.get();
return data;
},
});
}
// Get single product
export function useProduct(id: string) {
return useQuery({
queryKey: ["products", id],
queryFn: async () => {
const { data } = await api.api.products[id].get();
return data;
},
enabled: !!id,
});
}
// Create product
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (productData: {
name: string;
description?: string;
price: number;
stock: number;
category: string;
imageUrl?: string;
}) => {
const { data } = await api.api.products.post(productData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
},
});
}
// Update product
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
...productData
}: {
id: string;
name?: string;
description?: string;
price?: number;
stock?: number;
category?: string;
imageUrl?: string;
}) => {
const { data } = await api.api.products[id].put(productData);
return data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ["products"] });
queryClient.invalidateQueries({ queryKey: ["products", variables.id] });
},
});
}
// Delete product
export function useDeleteProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { data } = await api.api.products[id].delete();
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
},
});
}
// Get products by category
export function useProductsByCategory(category: string) {
return useQuery({
queryKey: ["products", "category", category],
queryFn: async () => {
const { data } = await api.api.products.category[category].get();
return data;
},
enabled: !!category,
});
}Usage Examples
Example 1: Products List Component (src/components/products-list.tsx)
'use client'
import { useProducts, useDeleteProduct } from '@/hooks/use-products'
export function ProductsList() {
const { data: products, isLoading, error } = useProducts()
const deleteProduct = useDeleteProduct()
if (isLoading) return <div>Loading products...</div>
if (error) return <div>Error loading products</div>
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{products?.map((product) => (
<div key={product.id} className="border p-4 rounded-lg">
<h3 className="font-bold text-lg">{product.name}</h3>
<p className="text-gray-600">{product.description}</p>
<p className="text-xl font-semibold">${product.price}</p>
<p className="text-sm">Stock: {product.stock}</p>
<p className="text-sm text-gray-500">{product.category}</p>
<button
onClick={() => deleteProduct.mutate(product.id)}
disabled={deleteProduct.isPending}
className="mt-2 bg-red-500 text-white px-4 py-2 rounded"
>
{deleteProduct.isPending ? 'Deleting...' : 'Delete'}
</button>
</div>
))}
</div>
)
}Example 2: Create Product Form (src/components/create-product-form.tsx)
'use client'
import { useCreateProduct } from '@/hooks/use-products'
import { useState } from 'react'
export function CreateProductForm() {
const createProduct = useCreateProduct()
const [formData, setFormData] = useState({
name: '',
description: '',
price: 0,
stock: 0,
category: '',
imageUrl: ''
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await createProduct.mutateAsync(formData)
// Reset form
setFormData({
name: '',
description: '',
price: 0,
stock: 0,
category: '',
imageUrl: ''
})
alert('Product created successfully!')
} catch (error) {
alert('Failed to create product')
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-md">
<div>
<label className="block text-sm font-medium mb-1">Product Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full border rounded px-3 py-2"
rows={3}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Price</label>
<input
type="number"
step="0.01"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) })}
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Stock</label>
<input
type="number"
value={formData.stock}
onChange={(e) => setFormData({ ...formData, stock: parseInt(e.target.value) })}
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Category</label>
<input
type="text"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Image URL</label>
<input
type="url"
value={formData.imageUrl}
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
className="w-full border rounded px-3 py-2"
/>
</div>
<button
type="submit"
disabled={createProduct.isPending}
className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600 disabled:bg-gray-400"
>
{createProduct.isPending ? 'Creating...' : 'Create Product'}
</button>
</form>
)
}Example 3: Update Product Form (src/components/update-product-form.tsx)
'use client'
import { useProduct, useUpdateProduct } from '@/hooks/use-products'
import { useEffect, useState } from 'react'
export function UpdateProductForm({ productId }: { productId: string }) {
const { data: product, isLoading } = useProduct(productId)
const updateProduct = useUpdateProduct()
const [formData, setFormData] = useState({
name: '',
description: '',
price: 0,
stock: 0,
category: '',
imageUrl: ''
})
useEffect(() => {
if (product) {
setFormData({
name: product.name,
description: product.description || '',
price: product.price,
stock: product.stock,
category: product.category,
imageUrl: product.imageUrl || ''
})
}
}, [product])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await updateProduct.mutateAsync({ id: productId, ...formData })
alert('Product updated successfully!')
} catch (error) {
alert('Failed to update product')
}
}
if (isLoading) return <div>Loading product...</div>
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-md">
{/* Similar form fields as CreateProductForm */}
<div>
<label className="block text-sm font-medium mb-1">Product Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
className="w-full border rounded px-3 py-2"
/>
</div>
{/* Add other fields similar to CreateProductForm */}
<button
type="submit"
disabled={updateProduct.isPending}
className="w-full bg-green-500 text-white py-2 rounded hover:bg-green-600 disabled:bg-gray-400"
>
{updateProduct.isPending ? 'Updating...' : 'Update Product'}
</button>
</form>
)
}Example 4: Main Page (src/app/page.tsx)
import { ProductsList } from '@/components/products-list'
import { CreateProductForm } from '@/components/create-product-form'
export default function Home() {
return (
<main className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Product Management</h1>
<div className="grid md:grid-cols-2 gap-8 mb-8">
<div>
<h2 className="text-2xl font-semibold mb-4">Create New Product</h2>
<CreateProductForm />
</div>
</div>
<div>
<h2 className="text-2xl font-semibold mb-4">All Products</h2>
<ProductsList />
</div>
</main>
)
}Deployment
Deploy to Vercel
-
Push your code to GitHub
-
Connect repository to Vercel
-
Add environment variables:
DATABASE_URL=your_production_database_url NEXT_PUBLIC_API_URL=https://your-domain.vercel.app -
Deploy:
vercel deploy
Vercel automatically detects Elysia apps and provisions optimal resources.
Deploy to Other Platforms
Elysia is WinterCG compliant, so you can deploy to:
- Netlify
- Cloudflare Pages
- AWS Lambda
- Any platform supporting WinterCG
Key Benefits
- Type Safety: Full end-to-end type safety from database to frontend
- Performance: Near Rust/Go performance levels (2.5M+ requests/sec)
- Developer Experience: Clean, intuitive API design
- Deployment Flexibility: Deploy anywhere with WinterCG compliance
- Built-in Validation: Schema validation with helpful error messages
- No Code Generation: Types work in real-time without build steps
Troubleshooting
Common Issues
Type errors in Eden client:
- Ensure you're exporting the
Apptype from your route file - Check that the API URL matches your development/production environment
Database connection errors:
- Verify your
DATABASE_URLis correct - Run
npx prisma generateafter schema changes - Check that migrations are applied:
npx prisma migrate deploy
API not responding:
- Verify the catch-all route is in the correct location:
src/app/api/[...slugs]/route.ts - Check that you're exporting GET, POST, PUT, DELETE handlers
- Ensure the Elysia prefix matches your API path
Additional Resources
License
This guide is provided as-is for educational purposes.

