Hono.js REST API for the XYNHub CMS. Read this before making changes.
| Item | Value |
|---|---|
| Framework | Hono 4.7 + @hono/node-server |
| Language | TypeScript 5.9 (strict) |
| Database | Supabase (PostgreSQL + Storage) |
| Dev Entry | src/index.ts → localhost:3000 |
| Prod Entry | src/vercel.ts → bundled to api/index.mjs |
| Main App | src/app.ts → middleware + routes + OpenAPI |
| Build | node build.mjs (esbuild → ESM, Node 20) |
| Swagger | http://localhost:3000/api/docs |
src/
├── index.ts # Dev: @hono/node-server on PORT
├── vercel.ts # Prod: Vercel serverless handler
├── app.ts # Main app: CORS → logger → secureHeaders → routes → errorHandler
├── types.ts # AppEnv { Variables: { user: User } }
├── lib/
│ └── supabase.ts # Lazy singleton clients via Proxy (admin + public + per-request)
├── middleware/
│ ├── auth.ts # Bearer token → Supabase getUser → email whitelist → 5s timeout
│ └── error-handler.ts # HTTPException / ZodError / generic 500
└── routes/
├── public/ # 12 route files (no auth)
│ ├── pages.ts blogs.ts portfolios.ts services.ts
│ ├── navigation.ts settings.ts faqs.ts testimonials.ts
│ ├── team.ts footer.ts contact.ts newsletter.ts
└── admin/ # 13 route files (auth required)
├── pages.ts blogs.ts portfolios.ts services.ts
├── navigation.ts settings.ts faqs.ts testimonials.ts
├── team.ts footer.ts media.ts
├── contact-messages.ts newsletter.ts
Request
→ OPTIONS → 204 with CORS headers (preflight, before any middleware)
→ CORS headers appended to all responses
→ logger() (Hono built-in)
→ secureHeaders() (X-Frame-Options, CSP, etc.)
→ Health: GET / and /health
→ Swagger: GET /api/docs and /api/openapi.json
→ Public: /api/v1/* (uses supabasePublic, respects RLS)
→ Admin: /api/v1/admin/* → authMiddleware → handler (uses supabaseAdmin, bypasses RLS)
→ errorHandler (catches HTTPException, ZodError, generic errors)
→ 404 fallback
- Extract
Authorization: Bearer <token>from request header - Call
supabaseAdmin.auth.getUser(token)with 5-second timeout (prevents Vercel 504 without CORS) - Check user email against
ALLOWED_ADMIN_EMAILSenv (comma-separated, case-insensitive) - Set
c.set("user", user)for downstream handlers - Returns: 401 (no/invalid token), 403 (email not whitelisted)
- Lazy singleton via Proxy pattern in
lib/supabase.ts— avoids crash at module load if env missing supabaseAdmin— service role key, bypasses RLS, used in admin routessupabasePublic— anon key, respects RLS, used in public routesgetSupabaseClient(c)— creates per-request client from Bearer token (for future use)- No explicit connection pooling — relies on Supabase JS internal pooling
// Success
{ success: true, data: T }
{ success: true, data: T[], pagination: { page, per_page, total, total_pages } }
{ success: true, message: "string" }
// Error
{ success: false, error: "string" }
{ success: false, error: "Validation error", details: [{ path, message }] }Status codes: 200, 201 (created), 400 (validation/ZodError), 401/403 (auth), 404, 500, 504 (timeout)
| Method | Path | Description |
|---|---|---|
| GET | /pages/:slug | All sections for page (object keyed by section_key) |
| GET | /pages/:slug/:section | Single section content |
| GET | /blogs | Published blogs (paginated, ?category, ?featured) |
| GET | /blogs/:slug | Blog detail |
| GET | /portfolios | Active portfolios (sorted) |
| GET | /portfolios/:slug | Portfolio + detail record |
| GET | /services | Active services (sorted, ?featured) |
| GET | /services/:slug | Service detail |
| GET | /navigation | Active top-level nav items (parent_id IS NULL) |
| GET | /settings | All settings as {key: value} object |
| GET | /faqs | FAQs by ?page (default "home") |
| GET | /testimonials | Active testimonials |
| GET | /team | Team members grouped by group_name |
| GET | /footer | Footer sections as object by section_key |
| POST | /contact | Submit contact form (public, no auth) |
| POST | /newsletter | Subscribe to newsletter (upsert on email) |
Full CRUD for: pages, blogs, portfolios, services, navigation, settings, faqs, testimonials, team, footer, media, contact-messages, newsletter
Key patterns:
- Public routes use
slug, admin routes useid(UUID) - Pagination:
?page=1&per_page=20→ response includespaginationobject - Upserts: pages (on page_slug+section_key), settings (on key), portfolio details (on portfolio_id)
- Portfolio create/update handles nested
detailobject - Media upload: multipart/form-data → Supabase Storage
media/uploads/{ts}-{rand}.{ext} - Validation: POST uses
schema.parse(), PUT usesschema.partial().parse()
Development:
npm run dev:api # tsx watch with .env loadedProduction build:
node build.mjs # esbuild → api/index.mjs (ESM, minified, Node 20)esbuild config highlights:
platform: "node"auto-externalizes Node.js built-insbanneraddscreateRequireshim for CommonJS deps- All npm packages are bundled (no external list)
Vercel:
vercel.json: framework=null, all requests rewrite to/api- Function config:
maxDuration: 30seconds - CORS headers set at infrastructure level (vercel.json) + backup in code
SUPABASE_URL=http://127.0.0.1:54321
SUPABASE_ANON_KEY=<key>
SUPABASE_SERVICE_ROLE_KEY=<key>
ALLOWED_ADMIN_EMAILS=user@example.com
PORT=3000CORS_ORIGINSis NOT used — CORS is wildcard (safe because no cookie-based auth)- Missing env vars throw at first use (lazy init), not at startup
- Each route file exports a
new Hono()instance, mounted inapp.ts - Route files import
supabaseAdminorsupabasePublicdirectly fromlib/supabase.ts - Zod schemas imported from
@xynhub/sharedfor input validation - Error responses always have
{ success: false, error: "message" } - DB errors are masked via
dbError()helper — logs real error server-side, returns generic message to client - OpenAPI spec is manually maintained in
app.ts(not auto-generated from routes) @hono/zod-openapiis installed but not used for route definitions
- CORS is
*everywhere — intentional since auth is stateless JWT (no cookies/credentials mode) - The
withTimeoutwrapper in auth.ts prevents Vercel's bare 504 (which has no CORS headers) - Path parameters (slug, id) are NOT validated with Zod before DB queries — Supabase JS uses parameterized queries so no SQL injection risk, but invalid inputs may cause unclear errors
parseInt()for pagination has no bounds checking — could accept negative/NaN values- Media cleanup (
/admin/media/cleanup) doesn't check for storage/DB operation failures - The OpenAPI spec in
app.tsmay drift from actual route implementations