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 @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 "@atlas/db"
export const users = defineSchema("users", {
id: column.serial().primaryKey(),
email: column.text().unique(),
name: column.text(),
passwordHash: column.text(),
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 "@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 "@atlas/db"
import { migrate } from "@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,
passwordHash TEXT NOT NULL,
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 "@atlas/db"
import { migrate } from "@atlas/migrate"
import {
serve,
pipeline,
parseMultipart,
json,
badRequest,
getR,
postR,
post,
parseJson,
} from "@atlas/server"
import { token, signup, login, requireAuth } from "@atlas/auth"
import { createStore, upload as uploadFile, presign } from "@atlas/storage"
import { admin, model } from "@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 },
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.
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 @atlas/ui blocks:
// frontend.tsx
import React from "react"
import { createRoot } from "react-dom/client"
import { AtlasProvider, AppShell } from "@atlas/ui/provider"
import { LoginPage } from "@atlas/ui/auth"
import { FileUpload } from "@atlas/ui/storage"
export default function App() {
const [token, setToken] = React.useState<string | null>(null)
if (!token) {
return (
<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) {
setToken(data.token)
return {}
}
return { error: "Login failed" }
}}
/>
)
}
return (
<AtlasProvider>
<AppShell>
<FileUpload
onUpload={async (file) => {
const form = new FormData()
form.append("file", file)
await fetch("/api/upload", {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: form,
})
}}
/>
</AppShell>
</AtlasProvider>
)
}
const root = createRoot(document.body)
root.render(<App />)
Add Caching#
import { createCache, cached } from "@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 "@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 "@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 "@atlas/auth/social"
import { token } from "@atlas/auth"
import { get, post, redirect, putHeader, parseForm, pipeline } from "@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 "@atlas/share"
import { createEmailer } from "@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 @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 "@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 "@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>
| Template | Description |
|---|---|
minimal | Just server + config |
api | REST API with db, auth, migrations |
edge | App + TLS-terminating edge (replaces Caddy/nginx) |
fullstack | API + React frontend |
admin | API + admin panel |
worker | Background job processor |
realtime | WebSocket + SSE |
socialnetwork | Users, posts, follows, likes, feeds, media, real-time |
cms | Headless CMS with content types, publishing, webhooks |
ai | Chatbot, 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)#
@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 "@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