Atlas Quick Start#

This guide walks you through building a complete app with user authentication, file uploads, and an admin panel.

Prerequisites#

  • Bun 1.0+ — Install from https://bun.sh
  • Postgres (optional) — For production; SQLite works for development
  • Redis (optional) — For caching; memory cache works for dev

This guide uses SQLite for simplicity.

Step 1: Create a Project#

mkdir myapp
cd myapp
bun init -y

Add Atlas:

bun add @wess/atlas

Every package is a subpath export (@wess/atlas/config, @wess/atlas/db, …). To use the @wess/atlas/<pkg> spelling this guide uses, map it via tsconfig.json paths (bun reads tsconfig paths at runtime) — the full mapping lives in the README's Install section.

Create .env:

DATABASE_URL="sqlite:./app.db"
PORT=3000
SECRET="dev-secret-key-change-in-production"
S3_ENDPOINT="http://localhost:9000"
S3_BUCKET="files"
S3_ACCESS_KEY="minioadmin"
S3_SECRET_KEY="minioadmin"

Step 2: Define Schemas#

Create src/schema.ts:

import { defineSchema, column } from "@wess/atlas/db"

export const users = defineSchema("users", {
  id: column.serial().primaryKey(),
  email: column.text().unique(),
  name: column.text(),
  password: column.text(),
  role: column.text().defaultRaw("'user'"),
  createdAt: column.timestamp().defaultRaw("CURRENT_TIMESTAMP"),
})

export const uploads = defineSchema("uploads", {
  id: column.serial().primaryKey(),
  userId: column.integer().ref("users", "id"),
  filename: column.text(),
  key: column.text(),
  size: column.integer(),
  contentType: column.text(),
  createdAt: column.timestamp().defaultRaw("CURRENT_TIMESTAMP"),
})

Step 3: Set Up Config#

Create src/config.ts:

import { defineConfig, env } from "@wess/atlas/config"

export const config = defineConfig({
  database: env("DATABASE_URL"),
  port: env("PORT", { parse: Number, default: "3000" }),
  secret: env("SECRET"),
  s3: {
    endpoint: env("S3_ENDPOINT"),
    bucket: env("S3_BUCKET"),
    accessKey: env("S3_ACCESS_KEY"),
    secretKey: env("S3_SECRET_KEY"),
  },
})

Step 4: Create Migrations#

You can hand-write migrations under migrations/<timestamp>_<name>/{up,down}.sql, or — preferred — generate them from the defineSchema() you already wrote:

// scripts/diff.ts
import { connect } from "@wess/atlas/db"
import { migrate } from "@wess/atlas/migrate"
import { users, uploads } from "../src/schema"

const db = connect({ driver: "sqlite", path: "./app.db" })
const result = await migrate.diff(db, [users, uploads], { name: "init" })
if (result.noop) console.log("schema in sync")
else console.log(`wrote ${result.path}`)
await db.close()
bun scripts/diff.ts   # writes migrations/<ts>_init/up.sql + down.sql

migrate.diff introspects the live database and emits SQL for any new tables, added/removed columns, and (as -- ALTER comments) type/nullability mismatches. Re-running it after a schema edit produces an incremental migration.

For reference, here's what an init migration ends up looking like:

-- migrations/<ts>_init/up.sql (generated)
CREATE TABLE users (
  id INTEGER PRIMARY KEY,
  email TEXT NOT NULL,
  name TEXT NOT NULL,
  password TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT 'user',
  createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE uploads (
  id INTEGER PRIMARY KEY,
  userId INTEGER NOT NULL,
  filename TEXT NOT NULL,
  key TEXT NOT NULL,
  size INTEGER NOT NULL,
  contentType TEXT NOT NULL,
  createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Step 5: Build the API#

Create src/server.ts:

import { config } from "./config"
import { users, uploads } from "./schema"
import { connect, from, type RowOf } from "@wess/atlas/db"
import { migrate } from "@wess/atlas/migrate"
import {
  serve,
  pipeline,
  parseMultipart,
  json,
  badRequest,
  getR,
  postR,
  post,
  parseJson,
} from "@wess/atlas/server"
import { token, signup, login, requireAuth, verify } from "@wess/atlas/auth"
import { from } from "@wess/atlas/db/query"
import { createStore, upload as uploadFile, presign } from "@wess/atlas/storage"
import { admin, model } from "@wess/atlas/admin"

// Connect to database
const db = connect({ driver: "sqlite", path: "./app.db" })

// Run migrations
await migrate.up(db, "./migrations")

// Storage setup
const store = createStore({
  endpoint: config.s3.endpoint,
  bucket: config.s3.bucket,
  accessKey: config.s3.accessKey,
  secretKey: config.s3.secretKey,
})

// Pipes that populate conn.assigns.auth (claims from the JWT).
const authGuard = requireAuth({ secret: config.secret })
type AuthClaims = { auth: { id: number } }

// Typed routes — c.assigns.auth.id is `number`, no casts.
const meRoute = getR<Record<string, never>, never, Record<string, string>, AuthClaims>(
  "/api/me",
  { before: [authGuard], assigns: {} as AuthClaims },
  (c) => json(c, 200, { id: c.assigns.auth.id }),
)

const filesRoute = getR<Record<string, never>, never, Record<string, string>, AuthClaims>(
  "/api/files",
  { before: [authGuard], assigns: {} as AuthClaims },
  async (c) => {
    type UploadRow = RowOf<typeof uploads>
    const rows: Pick<UploadRow, "id" | "filename" | "key" | "size" | "createdAt">[] = await db.all(
      from(uploads)
        .where((q) => q("userId").equals(c.assigns.auth.id))
        .select("id", "filename", "key", "size", "createdAt"),
    )
    return json(c, 200, rows)
  },
)

// Multipart upload still uses parseMultipart in `before` — typed body validators
// expect JSON. `throw badRequest(...)` becomes a 400 with { error, code? }.
const uploadRoute = post(
  "/api/upload",
  pipeline(authGuard, parseMultipart)(async (c) => {
    const userId = (c.assigns.auth as { id: number }).id
    const body = c.body as FormData
    const file = body.get("file") as File | null
    if (!file) throw badRequest("missing file", { code: "MISSING_FILE" })

    const key = `uploads/${userId}/${file.name}`
    await uploadFile(store, { key, body: file, contentType: file.type })

    const [created] = await db.execute(
      from(uploads)
        .insert({
          userId,
          filename: file.name,
          key,
          size: file.size,
          contentType: file.type,
        })
        .returning("id"),
    )

    return json(c, 201, {
      id: created?.id,
      filename: file.name,
      url: presign(store, key, { expires: 3600 }),
    })
  }),
)

// Admin panel
const adminPanel = admin({
  db,
  basePath: "/admin",
  auth: {
    secret: config.secret,
    authorize: (claims) => claims.role === "admin",
    login: async ({ email, password }) => {
      const user = await db.one(from(users).where((q) => q("email").equals(email)))
      return user && (await verify(password, user.password)) ? { userId: user.id, role: user.role } : null
    },
  },
  models: [
    model({
      schema: users,
      searchFields: ["email", "name"],
      filterFields: ["createdAt"],
    }),
    model({
      schema: uploads,
      searchFields: ["filename"],
      readOnly: true,
    }),
  ],
})

// Routes
serve({
  port: config.port,
  hostname: "0.0.0.0",
  routes: [
    // Auth
    post("/auth/signup", pipeline(parseJson)(
      signup({
        db,
        table: "users",
        fields: ["email", "name", "password"],
        onSuccess: (c, user) =>
          json(c, 201, {
            id: user.id,
            email: user.email,
            name: user.name,
          }),
      })
    )),

    post("/auth/login", pipeline(parseJson)(
      login({
        db,
        table: "users",
        identity: "email",
        password: "password",
        onSuccess: async (c, user) =>
          json(c, 200, {
            token: await token.sign({ id: user.id }, config.secret),
            user: { id: user.id, email: user.email, name: user.name },
          }),
      })
    )),

    // Protected API
    meRoute,
    uploadRoute,
    filesRoute,

    // Admin
    ...adminPanel.mount([]),
  ],

  development: true,
})

console.log(`Server running on http://localhost:${config.port}`)
console.log(`Admin panel at http://localhost:${config.port}/admin`)

Step 6: Run the Server#

bun src/server.ts

You should see:

Server running on http://localhost:3000
Admin panel at http://localhost:3000/admin

Step 7: Test the API#

Sign up a user:#

curl -X POST http://localhost:3000/auth/signup \
  -H "content-type: application/json" \
  -d '{
    "email": "[email protected]",
    "name": "John Doe",
    "password": "secure123"
  }'

Response:

{
  "id": 1,
  "email": "[email protected]",
  "name": "John Doe"
}

Log in:#

curl -X POST http://localhost:3000/auth/login \
  -H "content-type: application/json" \
  -d '{
    "email": "[email protected]",
    "password": "secure123"
  }'

Response:

{
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "user": { "id": 1, "email": "[email protected]", "name": "John Doe" }
}

Get authenticated user info:#

curl -X GET http://localhost:3000/api/me \
  -H "authorization: Bearer <token>"

Upload a file:#

curl -X POST http://localhost:3000/api/upload \
  -H "authorization: Bearer <token>" \
  -F "file=@/path/to/file.pdf"

List files:#

curl -X GET http://localhost:3000/api/files \
  -H "authorization: Bearer <token>"

Step 8: Access the Admin Panel#

Open http://localhost:3000/admin in your browser and sign in. The login function above decides who gets in, and authorize must accept the claims it returns, so give your account the admin role first: UPDATE users SET role = 'admin' WHERE email = '[email protected]';. Without login, the admin only accepts Authorization: Bearer tokens.

You'll see:

  • Users list with email and name search
  • Uploads list (read-only) showing all file uploads
  • Full CRUD for users (create, edit, delete)
  • Filters, bulk actions, custom query builder

Next Steps#

Add Frontend#

Create a React frontend using @wess/atlas/ui blocks:

// frontend.tsx
import "@wess/atlas/ui/styles.css"
import { LoginPage } from "@wess/atlas/ui/auth"
import { navigate, useLocation } from "@wess/atlas/ui/headless"
import { AppShell, AtlasProvider } from "@wess/atlas/ui/provider"
import { FileUpload } from "@wess/atlas/ui/storage"
import { createRoot } from "react-dom/client"

const authHeader = () => ({ authorization: `Bearer ${sessionStorage.getItem("token")}` })

const Login = () => (
  <LoginPage
    onSubmit={async ({ email, password }) => {
      const res = await fetch("/auth/login", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ email, password }),
      })
      const data = await res.json()
      if (!data.token) return { error: "That email and password don't match." }
      sessionStorage.setItem("token", data.token)
      navigate("/", { replace: true })
      return undefined
    }}
  />
)

const App = () => {
  const { pathname } = useLocation()
  const signedIn = Boolean(sessionStorage.getItem("token"))
  if (!signedIn && pathname !== "/login") navigate("/login", { replace: true })

  return (
    <AtlasProvider>
      {pathname === "/login" ? (
        <Login />
      ) : (
        <AppShell>
          <FileUpload
            onUpload={async (file) => {
              const form = new FormData()
              form.append("file", file)
              await fetch("/api/upload", { method: "POST", headers: authHeader(), body: form })
            }}
          />
        </AppShell>
      )}
    </AtlasProvider>
  )
}

createRoot(document.getElementById("root")!).render(<App />)

Every screen is a real path, so serve the same HTML for / and /login. Bundle this with Bun.build or import the HTML file in Bun.serve.

Add Caching#

import { createCache, cached } from "@wess/atlas/cache"

const cache = createCache({ url: process.env.REDIS_URL || "redis://localhost" })

const getUser = cached(cache, "user", async (id: number) => {
  return await db.one(from(users).where(q => q("id").equals(id)))
}, { ttl: 600 })

const user = await getUser(1) // cached for 10 minutes

Use Postgres in Production#

const db = connect({
  driver: "postgres",
  url: config.database,
  pool: 10,
})

Update migrations path as needed. Everything else stays the same.

Add AI#

import { createProvider } from "@wess/atlas/ai"

const openai = createProvider({ provider: "openai", key: process.env.OPENAI_API_KEY! })

const reply = await openai.chat({
  messages: [{ role: "user", content: "Summarize this document" }],
})

// Streaming
for await (const chunk of openai.chatStream({ messages: [{ role: "user", content: "Stream me" }] })) {
  if (chunk.type === "text") process.stdout.write(chunk.content ?? "")
}

Add the AI UI block to your frontend:

import { ChatWindow } from "@wess/atlas/ui/ai"

<ChatWindow messages={messages} onSend={(text) => sendToApi(text)} />

Add Social Login#

Drop "Sign in with Google / GitHub / etc." onto the existing password flow. PKCE + state ride in a signed HttpOnly cookie, so the flow stays stateless — no extra schema, no session table.

import { socialAuth, google, github, tiktok } from "@wess/atlas/auth/social"
import { token } from "@wess/atlas/auth"
import { get, post, redirect, putHeader, parseForm, pipeline } from "@wess/atlas/server"

const origin = `http://localhost:${config.port}`

const social = socialAuth({
  secret: process.env.OAUTH_STATE_SECRET!,
  cookie: { secure: process.env.NODE_ENV === "production" },
  providers: {
    google: google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      redirectUri: `${origin}/auth/google/callback`,
    }),
    github: github({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
      redirectUri: `${origin}/auth/github/callback`,
    }),
    tiktok: tiktok({
      clientKey: process.env.TIKTOK_CLIENT_KEY!,
      clientSecret: process.env.TIKTOK_CLIENT_SECRET!,
      redirectUri: `${origin}/auth/tiktok/callback`,
    }),
    // Add apple/microsoft/facebook/twitter the same way.
  },
})

const onSocialSuccess = async (c, { profile }) => {
  // Upsert by (provider, providerId); see docs/cookbook.md for the full pattern.
  const user = await upsertUserFromProfile(profile)
  const jwt = await token.sign({ id: user.id }, config.secret, { expiresIn: 86400 })
  return redirect(putHeader(c, "set-cookie", `session=${jwt}; HttpOnly; Path=/`), "/")
}

const socialRoutes = [
  get("/auth/google",           social.start("google")),
  get("/auth/google/callback",  social.callback("google", { onSuccess: onSocialSuccess })),
  get("/auth/github",           social.start("github")),
  get("/auth/github/callback",  social.callback("github", { onSuccess: onSocialSuccess })),
  get("/auth/tiktok",           social.start("tiktok")),
  get("/auth/tiktok/callback",  social.callback("tiktok", { onSuccess: onSocialSuccess })),
  // Apple delivers form_post → use POST + parseForm:
  // post("/auth/apple/callback", pipeline(parseForm)(social.callback("apple", { onSuccess: onSocialSuccess }))),
]

Slot ...socialRoutes into your serve({ routes: [...] }). The full provider matrix and Apple-specific notes live in docs/cookbook.md.

Add Share Buttons#

import { share, shareUrl, shareEmail } from "@wess/atlas/share"
import { createEmailer } from "@wess/atlas/email"

const content = { url: "https://example.com/post/123", title: "Look at this" }

// One channel:
shareUrl("twitter", { ...content, hashtags: ["atlas"] })

// All eight channels at once — render however your UI prefers:
share(content)
// → { twitter, facebook, linkedin, reddit, whatsapp, telegram, sms, email }

// Server-side share-by-email (reuses your @wess/atlas/email transport):
const emailer = createEmailer({ apiKey: process.env.RESEND_API_KEY, from: process.env.RESEND_FROM })
await shareEmail({ emailer, to: "[email protected]", sharerName: "Wess", content })

Add External API Calls#

import { github } from "@wess/atlas/request/providers"

const gh = github({ token: process.env.GITHUB_TOKEN! })
const repos = await (await gh.get("/user/repos")).json()

Add MCP Debugging#

import { collectTools, createContext, createMcpServer } from "@wess/atlas/mcp"

const ctx = createContext({ db, routes: myRoutes, config })
const mcp = createMcpServer(collectTools(ctx), ctx)
mcp.start()

Or launch via the CLI: atlas mcp

Templates#

Scaffold a complete project with atlas init:

atlas init -n myapp --template <template>
TemplateDescription
minimalJust server + config
apiREST API with db, auth, migrations
edgeApp + TLS-terminating edge (replaces Caddy/nginx)
fullstackAPI + React frontend
adminAPI + admin panel
workerBackground job processor
realtimeWebSocket + SSE
socialnetworkUsers, posts, follows, likes, feeds, media, real-time
cmsHeadless CMS with content types, publishing, webhooks
aiChatbot, RAG, agents, embeddings, streaming

Examples:

atlas init -n myapi --template api
atlas init -n mysite --template fullstack
atlas init -n mybot --template ai
atlas init -n mysocial --template socialnetwork

Deploy with TLS (no Caddy / nginx)#

@wess/atlas/edge terminates TLS, automates Let's Encrypt, and reverse-proxies to your app. The edge template ships a complete pattern — a one-line edge.ts, a Dockerfile, and a compose.yaml that drops the typical caddy sidecar entirely.

Add it to an existing app:

// edge.ts
import { LETSENCRYPT_PROD, LETSENCRYPT_STAGING, defineEdge, proxy } from "@wess/atlas/edge"

const isProd = Boolean(process.env.ADMIN_EMAIL)

defineEdge({
  acme: isProd
    ? {
        email: process.env.ADMIN_EMAIL!,
        storage: process.env.CERT_DIR ?? "/var/atlas/edge",
        directoryUrl: process.env.ACME_STAGING ? LETSENCRYPT_STAGING : LETSENCRYPT_PROD,
      }
    : undefined,
  sites: [{
    host: process.env.DOMAIN ?? "localhost",
    compress: ["gzip", "zstd"],
    routes: [{ handler: proxy(`http://localhost:${process.env.APP_PORT ?? 3000}`) }],
  }],
}).listen()

In dev (DOMAIN unset), the edge auto-detects localhost and runs plain HTTP on :8080 — no certs, no sudo. In production, it listens on :80

  • :443, issues real certs, and renews them automatically 30 days before expiry.

First prod boot — use staging once. Let's Encrypt's prod endpoint has a 5-failures-per-hostname-per-hour rate limit. Run with ACME_STAGING=1 the first time to verify the wiring (browser will show "untrusted issuer" — that's expected for staging certs), then clear the cert volume and bring the service back up without ACME_STAGING.

Troubleshooting#

SQLite database locked — Close other connections or use WAL mode:

PRAGMA journal_mode=WAL;

Admin panel 404 — Make sure migrations ran successfully:

const statuses = await migrate.status(db, "./migrations")
console.log(statuses)

S3 upload fails — Check endpoint and credentials in .env. MinIO local: http://localhost:9000

Auth token invalid — Make sure SECRET is the same in config and token.verify

Canonical sourcedocs/quickstart.md
Type to search guides and package references.