<< All versions
Skill v1.0.0
currentAutomated scan100/100alinaqi/maggy/supabase-node
──Details
PublishedOctober 1, 2026 at 08:11 PM
Content Hashsha256:78a32c66168e86f7...
Git SHA72a456e6ccf0
──Files
Files (1 file, 17.5 KB)
SKILL.md17.5 KBactive
SKILL.md · 838 lines · 17.5 KB
version: "1.0.0" name: supabase-node description: Express/Hono with Supabase and Drizzle ORM when-to-use: When building a Node.js backend with Supabase user-invocable: false paths: ["src/api/", "src/routes/", "supabase/**"] effort: medium
Supabase + Node.js Skill
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
bash
npm install express cors helmet dotenv @supabase/supabase-js drizzle-orm postgres zodnpm install -D typescript @types/express @types/cors @types/node tsx drizzle-kit
package.json Scripts
json
{"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
bash
# .envPORT=3000NODE_ENV=development# SupabaseSUPABASE_URL=http://localhost:54321SUPABASE_ANON_KEY=<from supabase start>SUPABASE_SERVICE_ROLE_KEY=<from supabase start># DatabaseDATABASE_URL=postgresql://postgres:postgres@localhost:54322/postgres
Configuration
src/lib/config.ts
typescript
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
typescript
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
typescript
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
typescript
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 exportsexport 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
typescript
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 userexport 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
typescript
import { User } from '@supabase/supabase-js';declare global {namespace Express {interface Request {user?: User;}}}export {};
Middleware
src/middleware/auth.ts
typescript
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 tokenexport 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
typescript
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
typescript
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
typescript
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
typescript
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 postsrouter.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 postsrouter.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 postrouter.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 postrouter.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 postrouter.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 postrouter.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
typescript
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
typescript
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 middlewareapp.use(helmet());app.use(cors());app.use(express.json());// Health checkapp.get('/health', (req, res) => {res.json({ status: 'healthy' });});// API routesapp.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
typescript
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
typescript
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)
typescript
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 routesapp.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 routesapp.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
typescript
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 tablesawait db.delete(posts);await db.delete(profiles);});afterAll(async () => {// Cleanup});
tests/posts.test.ts
typescript
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