Adding Teenybase to an Existing Hono Project

You already have a Hono app on Cloudflare Workers and you want to add auth, CRUD, row-level security, an admin panel, and auto-generated migrations -- all driven by a single config file. This guide covers manual setup for full understanding. You can also run teeny init inside your existing project directory to auto-detect what's already set up and generate only the missing files.

Using a coding agent?

Prerequisites

  • An existing Hono project deployed on Cloudflare Workers
  • Node.js >= 18.14.1
  • Wrangler CLI installed

1. Install Teenybase

bash
npm install teenybase

2. Create a D1 Database

bash
npx wrangler d1 create <your-database-name>

Note the database_id from the output -- you need it in the next step.

3. Configure Wrangler Bindings

Add the D1 database binding to your wrangler.jsonc (or wrangler.toml):

jsonc
{
    "d1_databases": [
        {
            "binding": "PRIMARY_DB",
            "database_name": "<your-database-name>",
            "database_id": "<your-database-id>",
            "migrations_dir": "migrations"
        }
    ],
    "vars": {
        "RESPOND_WITH_ERRORS": "true"  // Shows detailed errors in responses (disable in production)
    }
}

Enable Cloudflare Workers Logs for log streaming with teeny logs:

jsonc
{
    "observability": {
        "enabled": true,
        "logs": {
            "invocation_logs": true,
            "head_sampling_rate": 1
        }
    }
}

If you need file storage (e.g., for user avatars), also add an R2 bucket binding:

jsonc
{
    "r2_buckets": [
        {
            "binding": "PRIMARY_R2",
            "bucket_name": "<your-bucket-name>"
        }
    ]
}

After updating bindings, regenerate your type definitions:

bash
npx wrangler types --env-interface CloudflareBindings

This updates worker-configuration.d.ts to include PRIMARY_DB: D1Database (and PRIMARY_R2: R2Bucket if applicable).

4. Create the Database Config

Create teenybase.ts (or teeny.config.ts) in the project root. This single file defines your entire backend schema, auth, and access rules:

ts
// teenybase.ts
import { DatabaseSettings, TableAuthExtensionData, TableRulesExtensionData } from "teenybase"
import { baseFields, authFields, createdTrigger } from "teenybase/scaffolds/fields"

export default {
    appName: "My App",
    appUrl: "https://myapp.com",
    jwtSecret: "$JWT_SECRET_MAIN",

    // Optional: allow external auth providers (e.g., Google OAuth + One Tap)
    authProviders: [
        { name: 'google', clientId: '$GOOGLE_CLIENT_ID' },
    ],

    tables: [
        // Users table with authentication
        {
            name: "users",
            autoSetUid: true,
            fields: [
                ...baseFields,   // id, created, updated
                ...authFields,   // username, email, password, etc.
            ],
            triggers: [createdTrigger],
            extensions: [
                {
                    name: "auth",
                    jwtSecret: "$JWT_SECRET_USERS",
                    jwtTokenDuration: 3600,
                    maxTokenRefresh: 5,
                } as TableAuthExtensionData,
                {
                    name: "rules",
                    listRule: "auth.uid == id",
                    viewRule: "auth.uid == id",
                    createRule: "true",
                    updateRule: "auth.uid == id",
                    deleteRule: "auth.uid == id",
                } as TableRulesExtensionData,
            ],
        },

        // Example: Items table owned by users
        {
            name: "items",
            autoSetUid: true,
            fields: [
                ...baseFields,
                {
                    name: "owner_id", type: "relation", sqlType: "text", notNull: true,
                    foreignKey: { table: "users", column: "id" },
                },
                { name: "title", type: "text", sqlType: "text", notNull: true },
                { name: "content", type: "text", sqlType: "text" },
            ],
            triggers: [createdTrigger],
            extensions: [
                {
                    name: "rules",
                    listRule: "auth.uid == owner_id",
                    viewRule: "auth.uid == owner_id",
                    createRule: "auth.uid != null & owner_id == auth.uid",
                    updateRule: "auth.uid == owner_id",
                    deleteRule: "auth.uid == owner_id",
                },
    ],
        },
    ],

    // Optional: Email support via Resend
    email: {
        from: "My App <noreply@myapp.com>",
        tags: ["backend"],
        variables: {
            company_name: "My App",
            company_copyright: "My App Inc.",
            company_address: "San Francisco, CA",
            support_email: "support@myapp.com",
            company_url: "https://myapp.com",
        },
        resend: {
            RESEND_API_KEY: "$RESEND_API_KEY",
            RESEND_WEBHOOK_SECRET: "$RESEND_WEBHOOK_SECRET",
        },
    },
} satisfies DatabaseSettings

Values prefixed with $ (e.g., $JWT_SECRET_MAIN) are resolved from .prod.vars (uploaded to Cloudflare secrets) or .dev.vars during local development. To expand this config:

5. Add TypeScript Path Alias

Teenybase uses a virtual module (virtual:teenybase) to load the config at build time. Add this path mapping to your tsconfig.json:

json
{
    "compilerOptions": {
        "paths": {
            "virtual:teenybase": ["./teenybase"]
        }
    }
}

6. Update Your Worker Entry Point

Modify your main src/index.ts to use teenyHono() instead of creating a bare Hono app. This wraps your app with Teenybase middleware that initializes the database on each request.

ts
import { Hono } from "hono";
import { $Database, $Env, OpenApiExtension, PocketUIExtension, teenyHono } from "teenybase/worker";
import config from "virtual:teenybase";

// Merge Teenybase env types with your existing bindings
export interface Env {
    Bindings: $Env["Bindings"] & CloudflareBindings;
    Variables: $Env["Variables"];
}

// Create the Hono app with Teenybase middleware
const app = teenyHono<Env>(
    async (c) => {
        const db = new $Database(c, config, c.env.PRIMARY_DB /*, c.env.PRIMARY_R2 */);

        // Optional: OpenAPI/Swagger UI at /api/v1/docs
        db.extensions.push(new OpenApiExtension(db, true));

        // Optional: Admin UI at /api/v1/pocket
        db.extensions.push(new PocketUIExtension(db));

        return db;
    },
    undefined,
    {
        cors: {
            origin: [
                "http://localhost:3000",
                "http://localhost:5173",
                "https://myapp.com",
            ],
            allowHeaders: ["*"],
            allowMethods: ["POST", "GET", "OPTIONS", "PUT", "DELETE", "PATCH"],
            exposeHeaders: ["*"],
            maxAge: 600,
            credentials: true,
        },
    }
);

// Your existing routes continue to work below
app.get("/hello", (c) => c.text("Hello!"));

export default app;

Key points:

  • teenyHono() creates a Hono app with CORS, logging, and error handling middleware pre-configured.
  • The createDb callback runs per-request, initializing $Database with your config and D1 binding.
  • Your existing routes still work -- add them after the teenyHono() call.
  • R2 storage -- pass an R2 bucket as the fourth argument to $Database if you need file storage.

Using an Existing Hono App

teenyHono() accepts an existing Hono app as its second parameter. Instead of creating a new app, pass yours in -- Teenybase adds its middleware (CORS, logging, error handling, $Database init) to it:

typescript
import { Hono } from "hono"
import { $Database, $Env, teenyHono } from "teenybase/worker"
import config from "virtual:teenybase"

const app = new Hono<Env>()

// Your existing routes
app.get("/", (c) => c.text("Home"))
app.get("/health", (c) => c.json({ ok: true }))

// Add teenybase middleware and routes to your app
teenyHono<Env>(async (c) => {
    return new $Database(c, config, c.env.PRIMARY_DB)
}, app)

export default app

Your routes and Teenybase's /api/v1/* routes coexist on the same app.

Mounting Under a Sub-Path

If you want Teenybase at a custom prefix (e.g., /backend/api/v1/... instead of /api/v1/...), mount the Teenybase app using Hono's app.route():

typescript
import { Hono } from "hono"
import { $Database, $Env, teenyHono } from "teenybase/worker"
import config from "virtual:teenybase"

const teenyApp = teenyHono<Env>(async (c) => {
    return new $Database(c, config, c.env.PRIMARY_DB)
})

const app = new Hono<Env>()
app.get("/", (c) => c.text("Home"))
app.route("/backend", teenyApp)  // teenybase API at /backend/api/v1/...

export default app

Teenybase handles the prefix automatically -- all internal routing resolves correctly regardless of the mount path.

7. Set Up Secrets

Create a .dev.vars file for local development (this file should be in .gitignore):

env
JWT_SECRET_MAIN=your_main_jwt_secret
JWT_SECRET_USERS=your_users_jwt_secret
ADMIN_SERVICE_TOKEN=your_admin_service_token
ADMIN_JWT_SECRET=your_admin_jwt_secret
POCKET_UI_VIEWER_PASSWORD=viewer
POCKET_UI_EDITOR_PASSWORD=editor

Note: apiRoute is stored in infra.jsonc (CLI project config, committed to git) rather than in secrets files. It is auto-saved when you deploy.

Add more variables as needed (e.g., GOOGLE_CLIENT_ID, RESEND_API_KEY) -- see Getting Started: Environment Variables.

For production, create a .prod.vars file with production values and upload them:

bash
teeny secrets --remote --upload
bash
npx teeny secrets --remote --upload

8. Add Package Scripts

Add Teenybase CLI scripts to your package.json (see CLI Reference for all commands and options):

json
{
    "scripts": {
        "generate": "teeny generate --local",
        "migrate": "teeny deploy --local",
        "backup:local": "teeny backup --local",
        "dev": "teeny dev",
        "build:local": "teeny build --local",
        "exec": "teeny exec --local",

        "deploy": "teeny deploy --remote --log-level debug",
        "secrets-upload": "teeny secrets --remote --upload",
        "build": "teeny build --remote",
        "backup": "teeny backup --remote",
        "generate:remote": "teeny generate --remote",
        "migrate:remote": "teeny deploy --remote",

        "cf-typegen": "wrangler types --env-interface CloudflareBindings"
    }
}

9. Update .gitignore

Add Teenybase working directories to .gitignore:

gitignore
# Teenybase
.local-persist
.teeny
.tmp.*.json
.prod.vars
.prod.vars*
migrations

10. Generate Migrations and Run

With everything configured, generate and apply your initial migrations:

bash
teeny generate --local
teeny deploy --local
teeny dev --local
bash
npx teeny generate --local
npx teeny deploy --local
npx teeny dev --local

For the first remote deployment:

bash
teeny secrets --remote --upload
teeny deploy --remote
bash
npx teeny secrets --remote --upload
npx teeny deploy --remote

API Endpoints

See API Endpoints for the full endpoint reference. Key routes once running:

EndpointDescription
GET /api/v1/healthHealth check
GET /api/v1/doc/uiSwagger UI (if OpenApiExtension enabled)
GET /api/v1/pocket/Admin panel (if PocketUIExtension enabled)
GET /api/v1/table/{table}/selectQuery records
GET /api/v1/table/{table}/listQuery with total count
GET /api/v1/table/{table}/view/{id}View single record
POST /api/v1/table/{table}/insertCreate record
POST /api/v1/table/{table}/updateUpdate records
POST /api/v1/table/{table}/deleteDelete records
POST /api/v1/table/{table}/auth/sign-upRegister
POST /api/v1/table/{table}/auth/login-passwordLogin
POST /api/v1/table/{table}/auth/refresh-tokenRefresh JWT

Summary of Changes

FileChange
package.jsonAdded teenybase dependency and CLI scripts
wrangler.jsoncAdded D1 database binding and RESPOND_WITH_ERRORS var
worker-configuration.d.tsRegenerated to include PRIMARY_DB
tsconfig.jsonAdded virtual:teenybase path alias
teenybase.tsNew -- database schema and config
src/index.tsReplaced new Hono() with teenyHono() and $Database setup
.dev.varsNew -- local secrets
.gitignoreAdded Teenybase working directories