How to Create API Routes with Antigravity
Build production-ready REST APIs with Antigravity. Learn authentication, validation, rate limiting, error handling, and best practices.

How to Create API Routes with Antigravity
Overview
Learn how to build production-ready REST APIs with Antigravity in minutes. This guide covers Next.js API routes, server actions, authentication, validation, error handling, and best practices.
What You'll Learn
- Creating RESTful API endpoints
- Request validation with Zod
- Authentication and authorization
- Error handling patterns
- Rate limiting
- API documentation
- Testing endpoints
- Server Actions vs API Routes
Prerequisites
- Next.js 14 project
- Antigravity editor
- Basic REST API knowledge
API Routes vs Server Actions
API Routes
- Traditional REST endpoints
- Accessible from any client
- Can be called from external apps
- Support all HTTP methods
Server Actions
- Next.js 14 feature
- Type-safe client-server communication
- Only accessible from React components
- Automatic serialization
- Better DX for form handling
When to use each:
- API Routes: Public APIs, webhooks, external integrations
- Server Actions: Internal app logic, form submissions, mutations
Creating Your First API
Step 1: Start with a Prompt
Create a REST API for a task management system with:
ENDPOINTS:
- GET /api/tasks (list all tasks, with pagination)
- GET /api/tasks/[id] (get single task)
- POST /api/tasks (create new task)
- PUT /api/tasks/[id] (update task)
- DELETE /api/tasks/[id] (delete task)
- GET /api/tasks/stats (task statistics)
FEATURES:
- JWT authentication (protect all routes)
- Request validation with Zod
- Error handling with proper status codes
- Rate limiting (100 requests/hour per user)
- Pagination (default 10, max 100 per page)
- Filtering (status, priority, assignee)
- Sorting (createdAt, dueDate, priority)
DATA MODEL:
Task {
id: UUID
title: string (required, max 200 chars)
description: string (optional)
status: enum (TODO, IN_PROGRESS, DONE)
priority: enum (LOW, MEDIUM, HIGH)
dueDate: datetime (optional)
assigneeId: UUID
createdAt: datetime
updatedAt: datetime
}
RESPONSE FORMAT:
Success: {
success: true,
data: {...},
message: "Success message"
}
Error: {
success: false,
error: "Error message",
code: "ERROR_CODE"
}
TECH STACK:
- Next.js 14 API Routes
- Prisma ORM
- Zod validation
- JWT (jsonwebtoken)
- Rate limiting (upstash/ratelimit)
Step 2: Implementation
Antigravity generates:
File Structure
app/
├── api/
│ ├── tasks/
│ │ ├── route.ts (GET, POST)
│ │ ├── [id]/
│ │ │ └── route.ts (GET, PUT, DELETE)
│ │ └── stats/
│ │ └── route.ts (GET)
│ └── auth/
│ └── middleware.ts
lib/
├── validations/
│ └── task.ts (Zod schemas)
├── auth.ts (JWT helpers)
├── errors.ts (Error classes)
└── ratelimit.ts (Rate limit config)
GET /api/tasks (List)
// app/api/tasks/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { verifyToken } from '@/lib/auth';
import { rateLimit } from '@/lib/ratelimit';
import { TaskQuerySchema } from '@/lib/validations/task';
export async function GET(request: NextRequest) {
try {
// Authenticate
const user = await verifyToken(request);
if (!user) {
return NextResponse.json(
{ success: false, error: 'Unauthorized', code: 'AUTH_REQUIRED' },
{ status: 401 }
);
}
// Rate limit
const { success: rateLimitOk } = await rateLimit(user.id);
if (!rateLimitOk) {
return NextResponse.json(
{ success: false, error: 'Rate limit exceeded', code: 'RATE_LIMIT' },
{ status: 429 }
);
}
// Parse and validate query params
const { searchParams } = new URL(request.url);
const queryParams = {
page: parseInt(searchParams.get('page') || '1'),
limit: parseInt(searchParams.get('limit') || '10'),
status: searchParams.get('status'),
priority: searchParams.get('priority'),
assigneeId: searchParams.get('assigneeId'),
sortBy: searchParams.get('sortBy') || 'createdAt',
sortOrder: searchParams.get('sortOrder') || 'desc',
};
const validated = TaskQuerySchema.parse(queryParams);
// Build where clause
const where: any = {};
if (validated.status) where.status = validated.status;
if (validated.priority) where.priority = validated.priority;
if (validated.assigneeId) where.assigneeId = validated.assigneeId;
// Query with pagination
const [tasks, total] = await Promise.all([
prisma.task.findMany({
where,
skip: (validated.page - 1) * validated.limit,
take: validated.limit,
orderBy: { [validated.sortBy]: validated.sortOrder },
include: {
assignee: {
select: { id: true, name: true, email: true }
}
}
}),
prisma.task.count({ where })
]);
return NextResponse.json({
success: true,
data: tasks,
pagination: {
page: validated.page,
limit: validated.limit,
total,
totalPages: Math.ceil(total / validated.limit)
}
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
error: 'Validation failed',
code: 'VALIDATION_ERROR',
issues: error.errors
},
{ status: 400 }
);
}
console.error('GET /api/tasks error:', error);
return NextResponse.json(
{ success: false, error: 'Internal server error', code: 'SERVER_ERROR' },
{ status: 500 }
);
}
}
POST /api/tasks (Create)
export async function POST(request: NextRequest) {
try {
const user = await verifyToken(request);
if (!user) {
return NextResponse.json(
{ success: false, error: 'Unauthorized', code: 'AUTH_REQUIRED' },
{ status: 401 }
);
}
const body = await request.json();
const validated = TaskCreateSchema.parse(body);
const task = await prisma.task.create({
data: {
...validated,
assigneeId: user.id,
},
include: {
assignee: {
select: { id: true, name: true, email: true }
}
}
});
return NextResponse.json(
{
success: true,
data: task,
message: 'Task created successfully'
},
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
error: 'Validation failed',
code: 'VALIDATION_ERROR',
issues: error.errors
},
{ status: 400 }
);
}
console.error('POST /api/tasks error:', error);
return NextResponse.json(
{ success: false, error: 'Internal server error', code: 'SERVER_ERROR' },
{ status: 500 }
);
}
}
PUT /api/tasks/[id] (Update)
// app/api/tasks/[id]/route.ts
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await verifyToken(request);
if (!user) {
return NextResponse.json(
{ success: false, error: 'Unauthorized', code: 'AUTH_REQUIRED' },
{ status: 401 }
);
}
// Check task exists and user has permission
const existingTask = await prisma.task.findUnique({
where: { id: params.id }
});
if (!existingTask) {
return NextResponse.json(
{ success: false, error: 'Task not found', code: 'NOT_FOUND' },
{ status: 404 }
);
}
if (existingTask.assigneeId !== user.id && user.role !== 'ADMIN') {
return NextResponse.json(
{ success: false, error: 'Forbidden', code: 'FORBIDDEN' },
{ status: 403 }
);
}
const body = await request.json();
const validated = TaskUpdateSchema.parse(body);
const task = await prisma.task.update({
where: { id: params.id },
data: validated,
include: {
assignee: {
select: { id: true, name: true, email: true }
}
}
});
return NextResponse.json({
success: true,
data: task,
message: 'Task updated successfully'
});
} catch (error) {
// Error handling...
}
}
DELETE /api/tasks/[id]
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await verifyToken(request);
if (!user) {
return NextResponse.json(
{ success: false, error: 'Unauthorized', code: 'AUTH_REQUIRED' },
{ status: 401 }
);
}
const existingTask = await prisma.task.findUnique({
where: { id: params.id }
});
if (!existingTask) {
return NextResponse.json(
{ success: false, error: 'Task not found', code: 'NOT_FOUND' },
{ status: 404 }
);
}
if (existingTask.assigneeId !== user.id && user.role !== 'ADMIN') {
return NextResponse.json(
{ success: false, error: 'Forbidden', code: 'FORBIDDEN' },
{ status: 403 }
);
}
await prisma.task.delete({
where: { id: params.id }
});
return NextResponse.json({
success: true,
message: 'Task deleted successfully'
});
} catch (error) {
// Error handling...
}
}
Step 3: Validation Schemas
// lib/validations/task.ts
import { z } from 'zod';
export const TaskCreateSchema = z.object({
title: z.string()
.min(1, 'Title is required')
.max(200, 'Title must not exceed 200 characters'),
description: z.string().optional(),
status: z.enum(['TODO', 'IN_PROGRESS', 'DONE']).default('TODO'),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM'),
dueDate: z.string().datetime().optional(),
});
export const TaskUpdateSchema = TaskCreateSchema.partial();
export const TaskQuerySchema = z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(10),
status: z.enum(['TODO', 'IN_PROGRESS', 'DONE']).optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
assigneeId: z.string().uuid().optional(),
sortBy: z.enum(['createdAt', 'dueDate', 'priority']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
Step 4: Authentication
// lib/auth.ts
import { NextRequest } from 'next/server';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET!;
export async function verifyToken(request: NextRequest) {
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, JWT_SECRET) as {
userId: string;
email: string;
role: string;
};
return decoded;
} catch (error) {
return null;
}
}
export function generateToken(user: { id: string; email: string; role: string }) {
return jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '7d' }
);
}
Step 5: Rate Limiting
// lib/ratelimit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
export const rateLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '1 h'),
analytics: true,
});
Advanced API Patterns
1. Nested Resources
GET /api/users/[userId]/tasks
POST /api/users/[userId]/tasks
GET /api/projects/[projectId]/tasks
// app/api/users/[userId]/tasks/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { userId: string } }
) {
const tasks = await prisma.task.findMany({
where: { assigneeId: params.userId }
});
return NextResponse.json({ success: true, data: tasks });
}
2. Batch Operations
// app/api/tasks/batch/route.ts
export async function POST(request: NextRequest) {
const { operation, ids } = await request.json();
if (operation === 'delete') {
await prisma.task.deleteMany({
where: { id: { in: ids } }
});
} else if (operation === 'update') {
await prisma.task.updateMany({
where: { id: { in: ids } },
data: { status: 'DONE' }
});
}
return NextResponse.json({
success: true,
message: `${operation} completed for ${ids.length} tasks`
});
}
3. File Upload
// app/api/upload/route.ts
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ success: false, error: 'No file provided' },
{ status: 400 }
);
}
// Validate file
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ success: false, error: 'File too large (max 5MB)' },
{ status: 400 }
);
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ success: false, error: 'Invalid file type' },
{ status: 400 }
);
}
// Upload to storage (e.g., S3, UploadThing)
const uploadResult = await uploadFile(file);
return NextResponse.json({
success: true,
data: { url: uploadResult.url }
});
}
4. Webhooks
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = headers().get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Invalid signature' },
{ status: 400 }
);
}
// Handle event
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object;
await handlePaymentSuccess(paymentIntent);
break;
case 'customer.subscription.deleted':
const subscription = event.data.object;
await handleSubscriptionCanceled(subscription);
break;
}
return NextResponse.json({ received: true });
}
5. Server-Sent Events (SSE)
// app/api/events/route.ts
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// Send initial message
controller.enqueue(encoder.encode('data: Connected\n\n'));
// Send updates every 5 seconds
const interval = setInterval(() => {
const data = { timestamp: new Date().toISOString() };
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
);
}, 5000);
// Cleanup on close
request.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
Testing APIs
With curl
# GET with auth
curl -H "Authorization: Bearer <token>" \
http://localhost:3000/api/tasks
# POST with JSON
curl -X POST \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"title":"New Task","status":"TODO"}' \
http://localhost:3000/api/tasks
# PUT
curl -X PUT \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"status":"DONE"}' \
http://localhost:3000/api/tasks/123
# DELETE
curl -X DELETE \
-H "Authorization: Bearer <token>" \
http://localhost:3000/api/tasks/123
With Jest
// __tests__/api/tasks.test.ts
import { POST } from '@/app/api/tasks/route';
import { prisma } from '@/lib/prisma';
import { generateToken } from '@/lib/auth';
describe('POST /api/tasks', () => {
let authToken: string;
beforeAll(() => {
authToken = generateToken({
id: 'user-123',
email: '[email protected]',
role: 'USER'
});
});
it('creates a task with valid data', async () => {
const request = new Request('http://localhost:3000/api/tasks', {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'Test Task',
status: 'TODO',
priority: 'HIGH',
}),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.success).toBe(true);
expect(data.data.title).toBe('Test Task');
});
it('returns 401 without auth', async () => {
const request = new Request('http://localhost:3000/api/tasks', {
method: 'POST',
body: JSON.stringify({ title: 'Test' }),
});
const response = await POST(request);
expect(response.status).toBe(401);
});
it('validates required fields', async () => {
const request = new Request('http://localhost:3000/api/tasks', {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.code).toBe('VALIDATION_ERROR');
});
});
API Documentation
Use comments for auto-documentation:
/**
* @swagger
* /api/tasks:
* get:
* summary: List all tasks
* tags: [Tasks]
* security:
* - bearerAuth: []
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* description: Page number
* - in: query
* name: status
* schema:
* type: string
* enum: [TODO, IN_PROGRESS, DONE]
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: array
* items:
* $ref: '#/components/schemas/Task'
*/
export async function GET(request: NextRequest) {
// Implementation
}
Error Handling Best Practices
Custom Error Classes
// lib/errors.ts
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number,
public code: string
) {
super(message);
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(message, 400, 'VALIDATION_ERROR');
}
}
export class AuthenticationError extends AppError {
constructor() {
super('Unauthorized', 401, 'AUTH_REQUIRED');
}
}
export class ForbiddenError extends AppError {
constructor() {
super('Forbidden', 403, 'FORBIDDEN');
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
Global Error Handler
// lib/api-handler.ts
export function withErrorHandling(handler: Function) {
return async (request: NextRequest, context?: any) => {
try {
return await handler(request, context);
} catch (error) {
if (error instanceof AppError) {
return NextResponse.json(
{
success: false,
error: error.message,
code: error.code
},
{ status: error.statusCode }
);
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
error: 'Validation failed',
code: 'VALIDATION_ERROR',
issues: error.errors
},
{ status: 400 }
);
}
console.error('Unhandled error:', error);
return NextResponse.json(
{
success: false,
error: 'Internal server error',
code: 'SERVER_ERROR'
},
{ status: 500 }
);
}
};
}
// Usage
export const GET = withErrorHandling(async (request: NextRequest) => {
// Your handler code
});
Next Steps
Master API development with:
- Build CRUD: How to Build CRUD with Antigravity
- Prisma Integration: How to Extend Prisma Schema
- Best Practices: Antigravity Best Practices
- Example Projects: Antigravity Projects Examples
Conclusion
Antigravity makes API development fast and maintainable:
- Generate complete REST APIs in minutes
- Built-in validation, auth, and error handling
- Production-ready patterns
- Type-safe end-to-end
Start building APIs with Antigravity today!
Keywords: antigravity api example, nextjs api with antigravity, create api routes, antigravity rest api, api tutorial antigravity, nextjs api routes