| name | supabase-node |
| description | Express/Hono with Supabase and Drizzle ORM |
| disable-model-invocation | false |
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
PORT=3000
NODE_ENV=development
SUPABASE_URL=http://localhost:54321
SUPABASE_ANON_KEY=<from supabase start>
SUPABASE_SERVICE_ROLE_KEY=<from supabase start>
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,
});
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').(),
: (),
: ().(),
: ().().(),
});
= profiles.;
= profiles.;
= posts.;
= posts.;
Supabase Client
src/lib/supabase.ts
import { createClient, SupabaseClient, User } from '@supabase/supabase-js';
import { config } from './config';
export const supabase = createClient(
config.SUPABASE_URL,
config.SUPABASE_ANON_KEY
);
export const supabaseAdmin = createClient(
config.SUPABASE_URL,
config.SUPABASE_SERVICE_ROLE_KEY,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
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();
}
export async () {
authHeader = req..;
(authHeader?.()) {
token = authHeader.()[];
req. = (token) ?? ;
}
();
}
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.().({
: data.,
: data.,
});
} (error) {
(error);
}
});
router.(, (signInSchema), (req, res, next) => {
{
{ email, password } = req.;
{ data, error } = supabase..({
email,
password,
});
(error) {
res.().({ : });
}
res.({
: data.,
: data.,
});
} (error) {
(error);
}
});
router.(, (req, res) => {
supabase..();
res.({ : });
});
router.(, (req, res, next) => {
{
{ refresh_token } = req.;
{ data, error } = supabase..({
refresh_token,
});
(error) {
res.().({ : });
}
res.({
: data.,
});
} (error) {
(error);
}
});
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();
router.get('/', optionalAuth, async (req, res, next) => {
{
result = db
.()
.(posts)
.((posts., ))
.((posts.));
res.(result);
} (error) {
(error);
}
});
router.(, requireAuth, (req, res, next) => {
{
result = db
.()
.(posts)
.((posts., req.!.))
.((posts.));
res.(result);
} (error) {
(error);
}
});
router.(, (req, res, next) => {
{
[post] = db
.()
.(posts)
.((posts., req..))
.();
(!post) {
(, );
}
res.(post);
} (error) {
(error);
}
});
router.(, requireAuth, (createPostSchema), (req, res, next) => {
{
[post] = db
.(posts)
.({
...req.,
: req.!.,
})
.();
res.().(post);
} (error) {
(error);
}
});
router.(, requireAuth, (updatePostSchema), (req, res, next) => {
{
[post] = db
.(posts)
.(req.)
.((posts., req..))
.();
(!post) {
(, );
}
res.(post);
} (error) {
(error);
}
});
router.(, requireAuth, (req, res, next) => {
{
[post] = db
.(posts)
.((posts., req..))
.();
(!post) {
(, );
}
res.().();
} (error) {
(error);
}
});
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();
app.use(helmet());
app.use(cors());
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
app.use('/api', routes);
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((posts., userId))
.((posts.));
}
() {
[post] = db
.()
.(posts)
.((posts., id))
.();
post ?? ;
}
() {
[post] = db.(posts).(data).();
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(, );
}
{ data } = supabase.
.()
.(filePath);
res.({ : data. });
} (error) {
(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());
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);
});
app.use('/api/*', async (c, next) => {
const auth = c.req.header();
(!auth?.()) {
c.({ : }, );
}
();
});
app.(, (c) => {
body = c..();
[post] = db.(posts).(body).();
c.(post, );
});
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 () => {
});
beforeEach(async () => {
await db.delete(posts);
await db.delete(profiles);
});
afterAll(async () => {
});
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