Express/Hono with Supabase and Drizzle ORM
Supabase + Node.js Skill
Load with: base.md + supabase.md + typescript.md
Express/Hono patterns with Supabase Auth and Drizzle ORM.
Sources: Supabase JS Client | Drizzle ORM
Core Principle
Drizzle for queries, Supabase for auth/storage, middleware for validation.
Use Drizzle ORM for type-safe database access. Use Supabase client for auth verification, storage, and realtime. Express or Hono for the API layer.
Project Structure
project/
├── src/
│ ├── routes/
│ │ ├── index.ts # Route aggregator
│ │ ├── auth.ts
│ │ ├── posts.ts
│ │ └── users.ts
│ ├── middleware/
│ │ ├── auth.ts # JWT validation
│ │ ├── error.ts # Error handler
│ │ └── validate.ts # Request validation
│ ├── db/
│ │ ├── index.ts # Drizzle client
│ │ ├── schema.ts # Schema definitions
│ │ └── queries/ # Query functions
│ ├── lib/
│ │ ├── supabase.ts # Supabase client
│ │ └── config.ts # Environment config
│ ├── types/
│ │ └── express.d.ts # Express type extensions
│ └── index.ts # App entry point
├── supabase/
│ ├── migrations/
│ └── config.toml
├── drizzle.config.ts
├── package.json
├── tsconfig.json
└── .env
Setup
Install Dependencies
npm install express cors helmet dotenv @supabase/supabase-js drizzle-orm postgres zod
npm install -D typescript @types/express @types/cors @types/node tsx drizzle-kit
package.json Scripts
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
}
}
Environment Variables
# .env
PORT=3000
NODE_ENV=development
# Supabase
SUPABASE_URL=http://localhost:54321
SUPABASE_ANON_KEY=<from supabase start>
SUPABASE_SERVICE_ROLE_KEY=<from supabase start>
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:54322/postgres
Configuration
src/lib/config.ts
import { z } from 'zod';
import dotenv from 'dotenv';
dotenv.config();
const envSchema = z.object({
PORT: z.string().default('3000'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
SUPABASE_URL: z.string().url(),
SUPABASE_ANON_KEY: z.string(),
SUPABASE_SERVICE_ROLE_KEY: z.string(),
DATABASE_URL: z.string(),
});
export const config = envSchema.parse(process.env);
Database Setup
drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
import { config } from './src/lib/config';
export default defineConfig({
schema: './src/db/schema.ts',
out: './supabase/migrations',
dialect: 'postgresql',
dbCredentials: {
url: config.DATABASE_URL,
},
schemaFilter: ['public'],
});
src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
import { config } from '../lib/config';
const client = postgres(config.DATABASE_URL, {
prepare: false, // Required for Supabase pooling
});
export const db = drizzle(client, { schema });
src/db/schema.ts
import {
pgTable,
uuid,
text,
timestamp,
boolean,
} from 'drizzle-orm/pg-core';
export const profiles = pgTable('profiles', {
id: uuid('id').primaryKey(),
email: text('email').notNull(),
name: text('name'),
avatarUrl: text('avatar_url'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const posts = pgTable('posts', {
id: uuid('id').primaryKey().defaultRandom(),
authorId: uuid('author_id').references(() => profiles.id).notNull(),
title: text('title').notNull(),
content: text('content'),
published: boolean('published').default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Type exports
export type Profile = typeof profiles.$inferSelect;
export type NewProfile = typeof profiles.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
Supabase Client
src/lib/supabase.ts
import { createClient, SupabaseClient, User } from '@supabase/supabase-js';
import { config } from './config';
// Client with anon key (respects RLS)
export const supabase = createClient(
config.SUPABASE_URL,
config.SUPABASE_ANON_KEY
);
// Admin client (bypasses RLS)
export const supabaseAdmin = createClient(
config.SUPABASE_URL,
config.SUPABASE_SERVICE_ROLE_KEY,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
// Verify JWT and get user
export async function verifyToken(token: string): Promise<User | null> {
const { data: { user }, error } = await supabase.auth.getUser(token);
if (error || !user) {
return null;
}
return user;
}
Type Extensions
src/types/express.d.ts
import { User } from '@supabase/supabase-js';
declare global {
namespace Express {
interface Request {
user?: User;
}
}
}
export {};
Middleware
src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from '../lib/supabase';
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction
) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' });
}
const token = authHeader.split(' ')[1];
const user = await verifyToken(token);
if (!user) {
return res.status(401).json({ error: 'Invalid token' });
}
req.user = user;
next();
}
// Optional auth - continues even without token
export async function optionalAuth(
req: Request,
res: Response,
next: NextFunction
) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1];
req.user = await verifyToken(token) ?? undefined;
}
next();
}
src/middleware/error.ts
import { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
this.name = 'AppError';
}
}
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
console.error(err);
if (err instanceof AppError) {
return res.status(err.statusCode).json({ error: err.message });
}
return res.status(500).json({ error: 'Internal server error' });
}
src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { z, ZodSchema } from 'zod';
export function validate<T extends ZodSchema>(schema: T) {
return (req: Request, res: Response, next: NextFunction) => {
try {
req.body = schema.parse(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors,
});
}
next(error);
}
};
}
Routes
src/routes/auth.ts
import { Router } from 'express';
import { z } from 'zod';
import { supabase } from '../lib/supabase';
import { validate } from '../middleware/validate';
const router = Router();
const signUpSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const signInSchema = z.object({
email: z.string().email(),
password: z.string(),
});
router.post('/signup', validate(signUpSchema), async (req, res, next) => {
try {
const { email, password } = req.body;
const { data, error } = await supabase.auth.signUp({
email,
password,
});
if (error) {
return res.status(400).json({ error: error.message });
}
return res.status(201).json({
user: data.user,
session: data.session,
});
} catch (error) {
next(error);
}
});
router.post('/signin', validate(signInSchema), async (req, res, next) => {
try {
const { email, password } = req.body;
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return res.status(401).json({ error: 'Invalid credentials' });
}
return res.json({
user: data.user,
session: data.session,
});
} catch (error) {
next(error);
}
});
router.post('/signout', async (req, res) => {
await supabase.auth.signOut();
return res.json({ message: 'Signed out' });
});
router.post('/refresh', async (req, res, next) => {
try {
const { refresh_token } = req.body;
const { data, error } = await supabase.auth.refreshSession({
refresh_token,
});
if (error) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
return res.json({
session: data.session,
});
} catch (error) {
next(error);
}
});
export default router;
src/routes/posts.ts
import { Router } from 'express';
import { z } from 'zod';
import { eq, desc } from 'drizzle-orm';
import { db } from '../db';
import { posts, Post } from '../db/schema';
import { requireAuth, optionalAuth } from '../middleware/auth';
import { validate } from '../middleware/validate';
import { AppError } from '../middleware/error';
const router = Router();
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().optional(),
published: z.boolean().default(false),
});
const updatePostSchema = createPostSchema.partial();
// List all published posts
router.get('/', optionalAuth, async (req, res, next) => {
try {
const result = await db
.select()
.from(posts)
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt));
return res.json(result);
} catch (error) {
next(error);
}
});
// List user's posts
router.get('/me', requireAuth, async (req, res, next) => {
try {
const result = await db
.select()
.from(posts)
.where(eq(posts.authorId, req.user!.id))
.orderBy(desc(posts.createdAt));
return res.json(result);
} catch (error) {
next(error);
}
});
// Get single post
router.get('/:id', async (req, res, next) => {
try {
const [post] = await db
.select()
.from(posts)
.where(eq(posts.id, req.params.id))
.limit(1);
if (!post) {
throw new AppError(404, 'Post not found');
}
return res.json(post);
} catch (error) {
next(error);
}
});
// Create post
router.post('/', requireAuth, validate(createPostSchema), async (req, res, next) => {
try {
const [post] = await db
.insert(posts)
.values({
...req.body,
authorId: req.user!.id,
})
.returning();
return res.status(201).json(post);
} catch (error) {
next(error);
}
});
// Update post
router.patch('/:id', requireAuth, validate(updatePostSchema), async (req, res, next) => {
try {
const [post] = await db
.update(posts)
.set(req.body)
.where(eq(posts.id, req.params.id))
.returning();
if (!post) {
throw new AppError(404, 'Post not found');
}
return res.json(post);
} catch (error) {
next(error);
}
});
// Delete post
router.delete('/:id', requireAuth, async (req, res, next) => {
try {
const [post] = await db
.delete(posts)
.where(eq(posts.id, req.params.id))
.returning();
if (!post) {
throw new AppError(404, 'Post not found');
}
return res.status(204).send();
} catch (error) {
next(error);
}
});
export default router;
src/routes/index.ts
import { Router } from 'express';
import authRoutes from './auth';
import postRoutes from './posts';
const router = Router();
router.use('/auth', authRoutes);
router.use('/posts', postRoutes);
export default router;
Main Application
src/index.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import routes from './routes';
import { errorHandler } from './middleware/error';
import { config } from './lib/config';
const app = express();
// Security middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
// API routes
app.use('/api', routes);
// Error handler (must be last)
app.use(errorHandler);
app.listen(config.PORT, () => {
console.log(`Server running on port ${config.PORT}`);
});
export default app;
Query Functions
src/db/queries/posts.ts
import { db } from '../index';
import { posts, profiles } from '../schema';
import { eq, desc, and } from 'drizzle-orm';
export async function getPublishedPosts(limit = 10) {
return db
.select({
id: posts.id,
title: posts.title,
content: posts.content,
author: profiles.name,
createdAt: posts.createdAt,
})
.from(posts)
.innerJoin(profiles, eq(posts.authorId, profiles.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
.limit(limit);
}
export async function getUserPosts(userId: string) {
return db
.select()
.from(posts)
.where(eq(posts.authorId, userId))
.orderBy(desc(posts.createdAt));
}
export async function getPostById(id: string) {
const [post] = await db
.select()
.from(posts)
.where(eq(posts.id, id))
.limit(1);
return post ?? null;
}
export async function createPost(data: {
title: string;
content?: string;
authorId: string;
published?: boolean;
}) {
const [post] = await db.insert(posts).values(data).returning();
return post;
}
Storage
Upload Endpoint
import multer from 'multer';
import { supabase } from '../lib/supabase';
const upload = multer({ storage: multer.memoryStorage() });
router.post(
'/avatar',
requireAuth,
upload.single('file'),
async (req, res, next) => {
try {
if (!req.file) {
throw new AppError(400, 'No file uploaded');
}
const fileExt = req.file.originalname.split('.').pop();
const filePath = `${req.user!.id}/avatar.${fileExt}`;
const { error } = await supabase.storage
.from('avatars')
.upload(filePath, req.file.buffer, {
contentType: req.file.mimetype,
upsert: true,
});
if (error) {
throw new AppError(500, 'Upload failed');
}
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(filePath);
return res.json({ url: data.publicUrl });
} catch (error) {
next(error);
}
}
);
Hono Alternative
For edge deployments or lighter weight:
src/index.ts (Hono)
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { jwt } from 'hono/jwt';
import { db } from './db';
import { posts } from './db/schema';
import { eq, desc } from 'drizzle-orm';
import { config } from './lib/config';
const app = new Hono();
app.use('/*', cors());
// Public routes
app.get('/posts', async (c) => {
const result = await db
.select()
.from(posts)
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt));
return c.json(result);
});
// Protected routes
app.use('/api/*', async (c, next) => {
const auth = c.req.header('Authorization');
if (!auth?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}
// Verify with Supabase...
await next();
});
app.post('/api/posts', async (c) => {
const body = await c.req.json();
const [post] = await db.insert(posts).values(body).returning();
return c.json(post, 201);
});
export default app;
Testing
tests/setup.ts
import { beforeAll, afterAll, beforeEach } from 'vitest';
import { db } from '../src/db';
import { posts, profiles } from '../src/db/schema';
beforeAll(async () => {
// Setup test database
});
beforeEach(async () => {
// Clean tables
await db.delete(posts);
await db.delete(profiles);
});
afterAll(async () => {
// Cleanup
});
tests/posts.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import app from '../src/index';
describe('Posts API', () => {
it('should list published posts', async () => {
const res = await request(app)
.get('/api/posts')
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('should require auth to create post', async () => {
await request(app)
.post('/api/posts')
.send({ title: 'Test' })
.expect(401);
});
});
Anti-Patterns
- Using Supabase client for DB queries - Use Drizzle
- Sync JWT validation - Keep it async
- No input validation - Use Zod middleware
- Missing error handling - Use centralized error handler
- Hardcoded secrets - Use environment variables
- No request logging - Add morgan or pino
- Blocking the event loop - Use async throughout
- Service key in responses - Never expose
You Might Also Like
Related Skills

zig-system-calls
Guides using bun.sys for system calls and file I/O in Zig. Use when implementing file operations instead of std.fs or std.posix.
oven-sh
bun-file-io
Use this when you are working on file operations like reading, writing, scanning, or deleting files. It summarizes the preferred file APIs and patterns used in this repo. It also notes when to use filesystem helpers for directories.
anomalyco
vector-index-tuning
Optimize vector index performance for latency, recall, and memory. Use when tuning HNSW parameters, selecting quantization strategies, or scaling vector search infrastructure.
wshobson
similarity-search-patterns
Implement efficient similarity search with vector databases. Use when building semantic search, implementing nearest neighbor queries, or optimizing retrieval performance.
wshobson
dbt-transformation-patterns
Master dbt (data build tool) for analytics engineering with model organization, testing, documentation, and incremental strategies. Use when building data transformations, creating data models, or implementing analytics engineering best practices.
wshobson
event-store-design
Design and implement event stores for event-sourced systems. Use when building event sourcing infrastructure, choosing event store technologies, or implementing event persistence patterns.
wshobson