OpenAPI REST API
External REST API with Hono and automatic OpenAPI documentation
OpenAPI REST API
ProductReady includes a REST API built with Hono and automatic OpenAPI documentation. Perfect for:
- External integrations (mobile apps, third-party services)
- Public API for your SaaS
- Webhooks and callbacks
- Non-TypeScript clients
tRPC vs REST: Use tRPC for your Next.js frontend (type-safe, faster), use REST API for external clients.
API Documentation Options
There are three ways to access API documentation:
Swagger UI
Interactive API explorer with try-it-out feature
OpenAPI JSON
Raw OpenAPI 3.0 specification for tools & SDKs
API Reference
Integrated docs with Fumadocs styling
1. Swagger UI (/api/v1/doc)
The classic interactive API documentation. Test API calls directly from the browser.
http://localhost:3000/api/v1/doc📖 Open Swagger UI →
2. OpenAPI JSON (/api/v1/openapi.json)
Raw OpenAPI 3.0 specification. Use this for:
- Generating SDKs with tools like
openapi-generator - Importing into Postman, Insomnia, or other API clients
- CI/CD validation and contract testing
http://localhost:3000/api/v1/openapi.json📄 View OpenAPI JSON →
3. API Reference (Fumadocs Integration)
Beautiful, searchable API documentation integrated into the docs site using Fumadocs OpenAPI.
📚 Browse API Reference →
The API Reference is auto-generated from your OpenAPI schema. Run pnpm openapi:generate to update it.
Quick Start
2. Make Your First Request
# Health check
curl http://localhost:3000/api/v1/health
# Response
{
"status": "ok",
"timestamp": "2025-11-17T00:00:00.000Z",
"version": "1.0.0"
}Project Structure
src/
└── app/
└── api/
└── v1/
├── [[...server]]/
│ ├── route.ts # Next.js handler (delegates to Hono)
│ └── app.ts # Hono app definition
└── routes/
├── index.ts # Route registration
├── health.ts # Health check endpoint
├── tasks.ts # Tasks CRUD
└── auth.ts # Authentication endpointsCreating Endpoints
Basic Endpoint
Create src/app/api/v1/routes/hello.ts:
import { createRoute, z } from '@hono/zod-openapi';
import { app } from '../[[...server]]/app';
// Define OpenAPI schema
const route = createRoute({
method: 'get',
path: '/hello',
summary: 'Say hello',
tags: ['General'],
responses: {
200: {
description: 'Success',
content: {
'application/json': {
schema: z.object({
message: z.string(),
timestamp: z.string(),
}),
},
},
},
},
});
// Implement handler
app.openapi(route, (c) => {
return c.json({
message: 'Hello from ProductReady!',
timestamp: new Date().toISOString(),
});
});Register in src/app/api/v1/routes/index.ts:
import './hello'; // ← Add thisNow visit: http://localhost:3000/api/v1/hello 🎉
Endpoint with Parameters
import { createRoute, z } from '@hono/zod-openapi';
// URL: /api/v1/users/:id
const getUserRoute = createRoute({
method: 'get',
path: '/users/{id}',
summary: 'Get user by ID',
tags: ['Users'],
request: {
params: z.object({
id: z.string().uuid().openapi({
param: { name: 'id', in: 'path' },
example: '123e4567-e89b-12d3-a456-426614174000',
}),
}),
},
responses: {
200: {
description: 'User found',
content: {
'application/json': {
schema: z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
}),
},
},
},
404: {
description: 'User not found',
content: {
'application/json': {
schema: z.object({
error: z.string(),
}),
},
},
},
},
});
app.openapi(getUserRoute, async (c) => {
const { id } = c.req.valid('param');
const user = await db.select().from(users).where(eq(users.id, id)).limit(1);
if (!user[0]) {
return c.json({ error: 'User not found' }, 404);
}
return c.json(user[0]);
});POST Endpoint with Body
const createUserRoute = createRoute({
method: 'post',
path: '/users',
summary: 'Create new user',
tags: ['Users'],
request: {
body: {
content: {
'application/json': {
schema: z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8),
}),
},
},
},
},
responses: {
201: {
description: 'User created',
content: {
'application/json': {
schema: z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string(),
createdAt: z.string(),
}),
},
},
},
400: {
description: 'Invalid input',
content: {
'application/json': {
schema: z.object({
error: z.string(),
}),
},
},
},
},
});
app.openapi(createUserRoute, async (c) => {
const body = c.req.valid('json');
// Hash password
const hashedPassword = await hash(body.password);
// Insert user
const [user] = await db
.insert(users)
.values({
name: body.name,
email: body.email,
password: hashedPassword,
})
.returning();
// Don't return password!
const { password, ...safeUser } = user;
return c.json(safeUser, 201);
});Authentication
API Key Auth
Add authentication middleware:
import { createMiddleware } from 'hono/factory';
const apiKeyAuth = createMiddleware(async (c, next) => {
const apiKey = c.req.header('X-API-Key');
if (!apiKey) {
return c.json({ error: 'Missing API key' }, 401);
}
// Verify API key
const valid = await verifyApiKey(apiKey);
if (!valid) {
return c.json({ error: 'Invalid API key' }, 401);
}
await next();
});
// Use in routes
app.use('/api/v1/protected/*', apiKeyAuth);Bearer Token Auth
const bearerAuth = createMiddleware(async (c, next) => {
const authorization = c.req.header('Authorization');
if (!authorization?.startsWith('Bearer ')) {
return c.json({ error: 'Missing bearer token' }, 401);
}
const token = authorization.replace('Bearer ', '');
try {
const session = await verifyToken(token);
c.set('session', session);
await next();
} catch (err) {
return c.json({ error: 'Invalid token' }, 401);
}
});OAuth2 (Advanced)
For OAuth2 flows, use Better Auth's OAuth endpoints:
// src/app/api/v1/routes/auth.ts
import { auth } from '~/lib/auth';
app.get('/auth/login', async (c) => {
// Redirect to OAuth provider
return c.redirect(
`https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}`
);
});
app.get('/auth/callback', async (c) => {
const code = c.req.query('code');
// Exchange code for token
const session = await auth.api.signIn.social({
provider: 'github',
code,
});
return c.json({ token: session.token });
});Validation
Hono uses Zod for validation. All schemas are automatically documented in OpenAPI!
const schema = z.object({
email: z.string().email('Invalid email format'),
age: z.number().min(18, 'Must be 18+').max(120),
role: z.enum(['user', 'admin']).default('user'),
metadata: z.record(z.string()).optional(),
});
app.openapi(route, async (c) => {
const data = c.req.valid('json'); // ← Already validated!
// data is typed and validated
console.log(data.email); // ✅ string
console.log(data.role); // ✅ 'user' | 'admin'
});Error Handling
Standard Error Responses
// 400 Bad Request
return c.json({ error: 'Invalid input', details: validationErrors }, 400);
// 401 Unauthorized
return c.json({ error: 'Authentication required' }, 401);
// 403 Forbidden
return c.json({ error: 'Insufficient permissions' }, 403);
// 404 Not Found
return c.json({ error: 'Resource not found' }, 404);
// 500 Internal Server Error
return c.json({ error: 'Internal server error' }, 500);Global Error Handler
// src/app/api/v1/[[...server]]/app.ts
app.onError((err, c) => {
console.error('API Error:', err);
if (err instanceof ZodError) {
return c.json({
error: 'Validation failed',
details: err.errors,
}, 400);
}
return c.json({
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
}, 500);
});CORS
Enable CORS for external clients:
import { cors } from 'hono/cors';
app.use('/api/v1/*', cors({
origin: ['https://yourdomain.com', 'https://app.yourdomain.com'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
}));Development (allow all):
app.use('/api/v1/*', cors({
origin: '*', // ⚠️ Only for development!
}));Rate Limiting
Protect your API from abuse:
import { rateLimiter } from 'hono-rate-limiter';
app.use('/api/v1/*', rateLimiter({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: 'Too many requests, please try again later',
}));Per-route rate limiting:
const strictLimiter = rateLimiter({
windowMs: 60 * 1000, // 1 minute
max: 5, // 5 requests per minute
});
app.post('/api/v1/auth/login', strictLimiter, async (c) => {
// Login logic
});Versioning
ProductReady uses URL versioning (/api/v1/, /api/v2/, etc.):
/api/v1/users ← Current version
/api/v2/users ← New version with breaking changesCreate new version:
# Copy v1 to v2
cp -r src/app/api/v1 src/app/api/v2
# Update paths in v2
# /api/v1/* → /api/v2/*Testing
Test API endpoints with supertest:
import { testClient } from 'hono/testing';
import app from '~/app/api/v1/[[...server]]/app';
test('GET /hello returns greeting', async () => {
const client = testClient(app);
const res = await client.hello.$get();
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe('Hello from ProductReady!');
});Integration tests:
test('POST /users creates user', async () => {
const client = testClient(app);
const res = await client.users.$post({
json: {
name: 'Test User',
email: 'test@example.com',
password: 'password123',
},
});
expect(res.status).toBe(201);
const user = await res.json();
expect(user.email).toBe('test@example.com');
expect(user.password).toBeUndefined(); // Should not return password!
});OpenAPI Spec
Customize Documentation
// src/app/api/v1/[[...server]]/app.ts
import { OpenAPIHono } from '@hono/zod-openapi';
const app = new OpenAPIHono();
app.doc('/doc', {
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'ProductReady API',
description: 'RESTful API for ProductReady',
contact: {
name: 'API Support',
email: 'support@productready.com',
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT',
},
},
servers: [
{
url: 'http://localhost:3000',
description: 'Development server',
},
{
url: 'https://api.yourdomain.com',
description: 'Production server',
},
],
});Export OpenAPI JSON
Get the raw OpenAPI spec:
# Visit in browser
http://localhost:3000/api/v1/doc
# Or via curl
curl http://localhost:3000/api/v1/doc > openapi.jsonUse with code generators:
# Generate TypeScript client
npx openapi-typescript openapi.json -o schema.d.ts
# Generate Python client
pip install openapi-generator-cli
openapi-generator-cli generate -i openapi.json -g pythonBest Practices
✅ Do
- Version your API - Use
/api/v1/prefix - Validate all inputs - Use Zod schemas
- Return consistent errors - Standard error format
- Document everything - OpenAPI makes it easy
- Use proper HTTP methods - GET, POST, PUT, DELETE
- Return proper status codes - 200, 201, 400, 401, 404, 500
// ✅ Good
app.openapi(createRoute({
method: 'post',
path: '/users',
request: { body: { schema: UserSchema } },
responses: {
201: { description: 'Created', schema: UserResponse },
400: { description: 'Invalid input', schema: ErrorSchema },
},
}), async (c) => {
const data = c.req.valid('json');
const user = await createUser(data);
return c.json(user, 201);
});❌ Don't
- Don't skip validation - Always validate inputs
- Don't expose internal errors - Generic error messages in production
- Don't return sensitive data - Strip passwords, tokens, etc.
- Don't ignore security - Use auth, rate limiting, CORS
- Don't make breaking changes - Version your API instead
// ❌ Bad
app.post('/users', async (c) => {
const body = await c.req.json(); // ← No validation!
const user = await db.insert(users).values(body); // ← Unsafe!
return c.json(user); // ← Might return password!
});Troubleshooting
"Route not found" in Swagger
Issue: New route doesn't appear in /api/v1/doc
Fix: Import route in routes/index.ts:
import './my-new-route';CORS errors
Issue: Browser blocks requests from frontend
Fix: Enable CORS in app.ts:
import { cors } from 'hono/cors';
app.use('/api/v1/*', cors({ origin: '*' }));Validation errors unclear
Issue: Zod error messages confusing
Fix: Customize error messages:
z.string().min(8, 'Password must be at least 8 characters')Next Steps
- tRPC Guide - Internal type-safe APIs
- Authentication - Secure your endpoints
- Database - Query data in endpoints
- Testing - Test your API
Resources
- Hono Docs: hono.dev
- Zod OpenAPI: github.com/honojs/middleware
- OpenAPI Spec: swagger.io/specification