superapp

Introduction

A thin, secure data layer between your frontend and any database.

Chat in Claude

One library to connect your frontend to any database -- with authentication, row-level permissions, and type safety built in.

What is superapp?

superapp is three packages:

PackageWhat it does
@superapp/backendConnects to Postgres, MySQL, SQLite, or CSV via native drivers. Handles auth, enforces row-level permissions, runs queries.
@superapp/dbDrizzle ORM client built on Drizzle Proxy. You write standard Drizzle queries — the data you get back is already filtered, restricted, and validated by the backend's permission engine.
@superapp/authClient-side authentication. Uses better-auth by default, but supports custom adapters. Provides session management, React hooks, and pre-built auth UI components.
  ┌───────────────────────────────────────────────────────────┐
  │  YOUR FRONTEND (React, Next.js, etc.)                     │
  │                                                           │
  │   ┌────────────────────┐   ┌────────────────────────┐     │
  │   │  @superapp/db       │  │  @superapp/auth         │    │
  │   │  Drizzle Proxy      │  │  Session management     │    │
  │   │  db.select(...)     │  │  useSession()           │    │
  │   │  db.insert(...)     │  │  AuthCard               │    │
  │   └─────────┬──────────┘   └───────────┬────────────┘     │
  │             └──────────┬───────────────┘                  │
  └────────────────────────┼──────────────────────────────────┘

                           │ SQL + params + JWT (Drizzle Proxy)

  ┌───────────────────────────────────────────────────────────┐
  │  @superapp/backend                                        │
  │                                                           │
  │  1. Authenticate ── verify JWT, resolve user + roles      │
  │         │                                                 │
  │         ▼                                                 │
  │  2. Authorize ──── check permissions for this user        │
  │         │          • inject WHERE filters (user_id = ?)   │
  │         │          • restrict columns to allowed set      │
  │         │          • validate writes against rules        │
  │         ▼                                                 │
  │  3. Execute ────── run permission-filtered SQL directly     │
  └───────────────────────────┬───────────────────────────────┘

                ┌─────────────┼─────────────┐
                ▼             ▼             ▼
             Postgres      MySQL       SQLite / CSV

Every request from your frontend goes through this pipeline. You write normal Drizzle ORM queries — db.select(), db.insert(), etc. — and the Drizzle Proxy driver sends the parameterized SQL to the backend. The server verifies who the user is, applies permission filters to the SQL, and automatically scopes the data so that each user can only access their own data.

How it works

  1. Define your server -- connect databases, configure auth, declare permissions
  2. Generate types -- the CLI introspects your schema and outputs Drizzle table definitions
  3. Query from the frontend -- use standard Drizzle ORM syntax, permissions are enforced automatically

Full Example — E-commerce Backend

A complete e-commerce backend with products, orders, and customers — four roles, each with different access levels. This is all the backend code you need.

Server (server.ts):

import { createEngine } from '@superapp/backend'
import { betterAuthProvider } from '@superapp/backend/auth/better-auth'
import { createHonoMiddleware } from '@superapp/backend/adapters/hono'
import { Hono } from 'hono'
import { serve } from '@hono/node-server'

const engine = createEngine({
  connections: {
    main: process.env.PG_URL!,
  },
  auth: betterAuthProvider({ secret: process.env.AUTH_SECRET! }),

  permissions: {
    // ─── Products ───────────────────────────────────────────
    //
    // Everyone can browse. Only store managers can create/edit.

    browse_products: {
      table: 'main.products',
      roles: ['customer', 'support_agent', 'store_manager', 'admin'],
      select: {
        columns: ['id', 'name', 'description', 'price', 'category', 'image_url', 'in_stock'],
        where: { in_stock: { $eq: true } },
      },
    },

    manage_products: {
      table: 'main.products',
      roles: ['store_manager', 'admin'],
      select: {
        columns: ['id', 'name', 'description', 'price', 'category', 'image_url', 'in_stock', 'cost', 'sku', 'created_at'],
      },
      insert: {
        columns: ['name', 'description', 'price', 'category', 'image_url', 'cost', 'sku'],
        validate: {
          price: { $gt: 0 },
          cost: { $gte: 0 },
        },
        overwrite: { in_stock: true, created_at: '$now' },
      },
      update: {
        columns: ['name', 'description', 'price', 'category', 'image_url', 'in_stock', 'cost'],
        validate: { price: { $gt: 0 } },
        overwrite: { updated_at: '$now' },
      },
      delete: {
        where: { in_stock: { $eq: false } },
      },
    },

    // ─── Orders ─────────────────────────────────────────────
    //
    // Customers see own orders. Support can view and update status.
    // Store managers see everything.

    customer_orders: {
      table: 'main.orders',
      roles: ['customer'],
      select: {
        columns: ['id', 'total', 'status', 'shipping_address', 'created_at'],
        where: { customer_id: { $eq: '$user.id' } },
      },
      insert: {
        columns: ['shipping_address'],
        validate: {
          shipping_address: { $ne: '' },
        },
        overwrite: {
          customer_id: '$user.id',
          status: 'pending',
          total: 0,
          created_at: '$now',
        },
      },
      update: {
        columns: ['shipping_address'],
        where: {
          customer_id: { $eq: '$user.id' },
          status: { $eq: 'pending' },
        },
      },
      delete: {
        where: {
          customer_id: { $eq: '$user.id' },
          status: { $eq: 'pending' },
        },
      },
    },

    support_orders: {
      table: 'main.orders',
      roles: ['support_agent'],
      select: {
        columns: ['id', 'customer_id', 'total', 'status', 'shipping_address', 'created_at'],
      },
      update: {
        columns: ['status'],
        validate: {
          status: { $in: ['processing', 'shipped', 'delivered', 'refunded'] },
        },
        overwrite: { updated_at: '$now' },
      },
    },

    manage_orders: {
      table: 'main.orders',
      roles: ['store_manager', 'admin'],
      select: {
        columns: ['id', 'customer_id', 'total', 'status', 'shipping_address', 'notes', 'created_at', 'updated_at'],
      },
      update: {
        columns: ['status', 'notes', 'total'],
        validate: {
          status: { $in: ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'] },
          total: { $gte: 0 },
        },
        overwrite: { updated_at: '$now' },
      },
      delete: {
        where: { status: { $in: ['cancelled', 'refunded'] } },
      },
    },

    // ─── Order Items ────────────────────────────────────────

    customer_order_items: {
      table: 'main.order_items',
      roles: ['customer'],
      select: {
        columns: ['id', 'order_id', 'product_id', 'quantity', 'unit_price'],
        where: { order_id: { $in: '$user.order_ids' } },
      },
      insert: {
        columns: ['order_id', 'product_id', 'quantity'],
        validate: {
          quantity: { $gt: 0, $lte: 99 },
        },
      },
      delete: {
        where: { order_id: { $in: '$user.order_ids' } },
      },
    },

    view_order_items: {
      table: 'main.order_items',
      roles: ['support_agent', 'store_manager', 'admin'],
      select: {
        columns: ['id', 'order_id', 'product_id', 'quantity', 'unit_price'],
      },
    },

    // ─── Customers ──────────────────────────────────────────

    customer_profile: {
      table: 'main.customers',
      roles: ['customer'],
      select: {
        columns: ['id', 'name', 'email', 'phone', 'created_at'],
        where: { id: { $eq: '$user.id' } },
      },
      update: {
        columns: ['name', 'phone'],
        where: { id: { $eq: '$user.id' } },
      },
    },

    view_customers: {
      table: 'main.customers',
      roles: ['support_agent'],
      select: {
        columns: ['id', 'name', 'email', 'phone', 'created_at'],
      },
    },

    manage_customers: {
      table: 'main.customers',
      roles: ['store_manager', 'admin'],
      select: {
        columns: ['id', 'name', 'email', 'phone', 'lifetime_value', 'notes', 'created_at'],
      },
      update: {
        columns: ['notes'],
        overwrite: { updated_at: '$now' },
      },
    },
  },
})

const app = new Hono()
app.route('/', createHonoMiddleware(engine))
serve({ fetch: app.fetch, port: 3001 })

What each role can do:

ProductsOrdersOrder ItemsCustomers
customerBrowse in-stockOwn orders only — create, edit pending, cancel pendingOwn items — add, removeOwn profile — view, edit name/phone
support_agentBrowse in-stockView all — update status (processing → shipped → delivered → refunded)View allView all
store_managerFull CRUD + see cost/SKUFull access + notes, delete cancelled/refundedView allView all + lifetime_value, edit notes
adminSame as store_managerSame as store_managerView allSame as store_manager

Client (lib/superapp.ts):

import { drizzle } from '@superapp/db'
import { createAuth } from '@superapp/auth'
import * as schema from '../generated/schema'

export const authClient = createAuth('http://localhost:3001')

export function createDb(token: string) {
  return drizzle({
    connection: 'http://localhost:3001',
    token,
    schema,
  })
}

Query (anywhere in your app):

import { eq, desc } from 'drizzle-orm'

// Customer sees only their own orders — backend injects WHERE customer_id = ?
const orders = await db.select()
  .from(schema.orders)
  .orderBy(desc(schema.orders.createdAt))
  .limit(50)

// Support agent runs the same query — sees ALL orders (no WHERE injected)
// Store manager runs it — sees all orders + notes column

// Create an order — backend forces customer_id, status, and timestamps
await db.insert(schema.orders)
  .values({ shipping_address: '123 Main St' })

// Support updates status — backend validates the transition is allowed
await db.update(schema.orders)
  .set({ status: 'shipped' })
  .where(eq(schema.orders.id, orderId))

// Customer tries to set status to 'shipped' — rejected with 403
// Customer tries to delete a shipped order — rejected with 403
// Support tries to delete any order — rejected (no delete permission)

Same query, different results per role. A customer calling db.select().from(orders) gets their own orders. A support agent gets all orders. A store manager gets all orders plus internal fields like notes. The backend decides — your frontend code stays identical.

Because the authorization layer lives entirely in the backend middleware, you can safely use Drizzle ORM directly from the client side — the data returned is always scoped to the user's permissions. That said, we still recommend keeping Drizzle queries in your backend (e.g. Next.js server actions, API routes) for better control over caching, error handling, and request batching.

What's Next

On this page