Claude Code Plugins

Community-maintained marketplace

Feedback
321
0

Express/Hono with Supabase and Drizzle ORM

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name supabase-node
description 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