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