Connecting Your Frontend
Teenybase exposes a standard REST API. You can call it from React, Vue, Svelte, vanilla JS, or mobile using fetch or any HTTP client. No SDK needed.
Base URL
| Environment | Base URL |
|---|---|
| Local development | http://localhost:8787/api/v1 |
| Teenybase Cloud | Run teeny status to get your production URL, then append /api/v1 |
| Self-hosted | Your Cloudflare Workers URL + /api/v1 |
const API = 'http://localhost:8787/api/v1'Authentication Flow
Sign Up
const response = await fetch(`${API}/table/users/auth/sign-up`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: 'alice',
email: 'alice@example.com',
password: 'securepassword',
name: 'Alice',
}),
})
const { token, refresh_token, record } = await response.json()
// Store both token and refresh_tokenResponse:
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "a1b2c3d4e5f6g7h8i9j0kl",
"record": {
"id": "abc123",
"username": "alice",
"email": "alice@example.com",
"name": "Alice",
"email_verified": false,
"role": null,
"avatar": null,
"meta": null,
"created": "2025-03-11T10:00:00.000Z",
"updated": "2025-03-11T10:00:00.000Z"
},
"verified": false
}Login
const response = await fetch(`${API}/table/users/auth/login-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identity: 'alice@example.com', // email or username
password: 'securepassword',
}),
})
const { token, refresh_token, record } = await response.json()Authenticated Requests
Include the JWT token in the Authorization header:
const response = await fetch(`${API}/table/posts/select`, {
headers: { 'Authorization': `Bearer ${token}` },
})
const posts = await response.json()The X-Authorization header also works as an alternative.
Refreshing Tokens
Tokens expire after jwtTokenDuration seconds (default: 1 hour). Use the refresh token to get a new pair without re-entering credentials.
const response = await fetch(`${API}/table/users/auth/refresh-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`, // Current (possibly expired) token
},
body: JSON.stringify({
refresh_token: refreshToken, // The refresh token from login/sign-up
}),
})
const { token: newToken, refresh_token: newRefreshToken, record } = await response.json()
// Replace stored tokens with new onesKey details:
- Each token can be refreshed up to
maxTokenRefreshtimes (default: 5) - After that limit, you must login again
- The refresh token is a 22-character string, different from the JWT
Change Password
const response = await fetch(`${API}/table/users/auth/change-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
passwordCurrent: 'oldpassword',
password: 'newpassword',
}),
})This invalidates all other sessions for the user.
Password Reset (Forgot Password)
Two-step flow: request a reset email, then confirm with the token from the email.
// Step 1: Request reset email
await fetch(`${API}/table/users/auth/request-password-reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: 'alice@example.com' }),
})
// Step 2: Confirm reset (user clicks link in email, your frontend extracts the token)
const response = await fetch(`${API}/table/users/auth/confirm-password-reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: 'RESET_TOKEN_FROM_EMAIL',
password: 'newpassword',
}),
})
const { token, refresh_token, record } = await response.json()
// User is now logged in with new credentialsEmail Verification
// Request verification email (must be authenticated)
await fetch(`${API}/table/users/auth/request-verification`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
})
// Confirm verification (user clicks link in email)
const response = await fetch(`${API}/table/users/auth/confirm-verification`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: 'VERIFICATION_TOKEN_FROM_EMAIL',
}),
})Logout
await fetch(`${API}/table/users/auth/logout`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
})
// Clear stored tokensCRUD Operations
All CRUD endpoints are at /api/v1/table/{tableName}/. Most accept both GET (query params) and POST (JSON body).
List Records (with Count)
Returns items and total count -- useful for pagination.
// GET — simple queries
const response = await fetch(
`${API}/table/posts/list?limit=10&offset=0&order=created DESC`
)
// POST — complex queries
const response = await fetch(`${API}/table/posts/list`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
where: "published == true & category == 'tech'",
limit: 10,
offset: 0,
order: 'created DESC',
select: 'id,title,created',
}),
})
const { items, total } = await response.json()
// items: [{id, title, created}, ...]
// total: 42Select Records (without Count)
Same as list, but returns a flat array without the total count. Faster when you don't need pagination info.
const response = await fetch(`${API}/table/posts/select`, {
headers: { 'Authorization': `Bearer ${token}` },
})
const posts = await response.json()
// [{id, title, body, ...}, ...]View Single Record
const response = await fetch(`${API}/table/posts/view/${postId}`, {
headers: { 'Authorization': `Bearer ${token}` },
})
const post = await response.json()
// {id, title, body, published, created, ...}Insert
// Single record
const response = await fetch(`${API}/table/posts/insert`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
values: {
title: 'My First Post',
body: 'Hello world',
published: true,
},
}),
})
const [inserted] = await response.json()
// Batch insert
const response = await fetch(`${API}/table/posts/insert`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
values: [
{ title: 'Post 1', body: 'Content 1' },
{ title: 'Post 2', body: 'Content 2' },
],
returning: ['id', 'title'], // Optional: specify which fields to return
}),
})Conflict handling: Add or to control behavior when a unique constraint is violated:
body: JSON.stringify({
values: { email: 'alice@example.com', name: 'Alice' },
or: 'IGNORE', // ABORT | FAIL | IGNORE | REPLACE | ROLLBACK
})Update (by Filter)
Update multiple records matching a filter expression.
const response = await fetch(`${API}/table/posts/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
where: "author_id == 'abc123' & published == false",
setValues: {
published: true,
updated_by: 'abc123',
},
returning: ['id', 'title'],
}),
})
const updatedPosts = await response.json()You can also use SQL expressions with set (instead of setValues):
body: JSON.stringify({
where: "id == 'post123'",
set: { view_count: 'view_count + 1' }, // SQL expression
})Edit (by ID)
Update a single record by its ID. Simpler than update-by-filter for single record changes.
const response = await fetch(`${API}/table/posts/edit/${postId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
setValues: {
title: 'Updated Title',
body: 'Updated content',
},
}),
})
const updatedPost = await response.json()Upsert (insert if not exists): Add or: 'INSERT' to create the record if the ID doesn't exist:
const response = await fetch(`${API}/table/settings/edit/user_prefs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
setValues: { theme: 'dark', language: 'en' },
or: 'INSERT',
}),
})Delete
const response = await fetch(`${API}/table/posts/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
where: "id == 'post123'",
returning: ['id', 'title'], // Optional: return deleted records
}),
})
const deletedPosts = await response.json()File Uploads
Fields with type: 'file' accept file uploads via multipart/form-data.
Uploading a File
const formData = new FormData()
// Add the file
formData.append('@filePayload', fileInput.files[0])
// Add the JSON payload referencing the file
formData.append('@jsonPayload', JSON.stringify({
values: {
name: 'My Photo',
avatar: '@filePayload.0', // References first file in @filePayload
},
}))
const response = await fetch(`${API}/table/users/insert`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
// Don't set Content-Type — browser sets it with boundary
body: formData,
})Uploading Multiple Files
const formData = new FormData()
formData.append('@filePayload', file1)
formData.append('@filePayload', file2)
formData.append('@jsonPayload', JSON.stringify({
values: {
name: 'Document Set',
thumbnail: '@filePayload.0', // First file
document: '@filePayload.1', // Second file
},
}))Downloading Files
Files are served at:
GET /api/v1/files/{table}/{recordId}/{field}// Get the file URL from a record
const fileUrl = `${API.replace('/api/v1', '')}/api/v1/files/users/${record.id}/avatar`
// Use in an <img> tag
<img src={fileUrl} alt="Avatar" />Query Parameters
These parameters work on /select and /list endpoints, both as GET query params and POST body fields.
where (Filtering)
Filter records using expression syntax. Same operators as rule expressions.
// Simple equality
where: "published == true"
// Multiple conditions (AND)
where: "published == true & category == 'tech'"
// OR conditions
where: "status == 'active' | status == 'pending'"
// LIKE pattern matching
where: "title ~ '%javascript%'"
// Comparison
where: "price > 10 & price <= 100"
// Check for null
where: "deleted_at == null"order (Sorting)
// Single field, descending
order: "created DESC"
// Multiple fields
order: ["created DESC", "title"] // or as string: "-created,title"
// Using +/- prefix shorthand
order: ["-created", "+title"] // - = DESC, + = ASClimit / offset (Pagination)
// Page 1: first 20 records
{ limit: 20, offset: 0 }
// Page 2: next 20 records
{ limit: 20, offset: 20 }select (Field Selection)
Return only specific fields to reduce response size.
// Comma-separated string
select: "id,title,created"
// Array
select: ["id", "title", "created"]
// All fields (default)
select: "*"distinct
Return only unique rows.
{ distinct: true, select: "category" }group (Grouping)
Group results by field(s). Useful with aggregate functions in select.
{
select: "category, COUNT(*) as count",
group: "category",
order: "count DESC",
}Error Handling
All errors follow a consistent JSON format.
Error Response Format
{
"code": 400,
"message": "Validation Error",
"data": {
"title": { "_errors": ["Required"] }
}
}Common Status Codes
| Status | Meaning | When |
|---|---|---|
| 400 | Bad Request | Invalid input, validation errors, malformed JSON |
| 401 | Unauthorized | Missing or expired auth token |
| 403 | Forbidden | Valid token but insufficient permissions (rule denied) |
| 404 | Not Found | Record or endpoint doesn't exist |
| 409 | Conflict | Duplicate key violation (with FAIL conflict strategy) |
| 500 | Server Error | Internal error |
Handling Errors in Code
async function apiCall(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
...options.headers,
},
})
if (!response.ok) {
const error = await response.json()
if (response.status === 401) {
// Token expired — try refreshing
const refreshed = await refreshAuth()
if (refreshed) return apiCall(url, options) // Retry
// Redirect to login
}
throw new Error(error.message || 'API error')
}
return response.json()
}CORS
Teenybase includes permissive CORS defaults suitable for development and most production setups:
| Header | Default Value |
|---|---|
Access-Control-Allow-Origin | * |
Access-Control-Allow-Methods | POST, GET, OPTIONS, PUT, DELETE, PATCH |
Access-Control-Allow-Headers | * |
Access-Control-Expose-Headers | * |
Access-Control-Max-Age | 600 (10 minutes) |
Access-Control-Allow-Credentials | true |
Preflight (OPTIONS) requests are handled automatically.
WARNING
If you're using authCookie with sameSite: 'None', make sure your frontend and backend are on HTTPS in production. Browsers block cross-site cookies over HTTP.
OAuth from Frontend
Starting the OAuth Flow
Redirect to the OAuth authorization endpoint:
// Redirect to Google OAuth
window.location.href = `${API}/table/users/auth/oauth/google?redirect=${encodeURIComponent(window.location.origin + '/auth/callback')}`The redirect query parameter tells teenybase where to send you after authentication. Relative paths are resolved against appUrl. By default, only URLs matching the appUrl hostname are allowed -- configure allowedRedirectUrls for other domains. See OAuth Guide -- Redirect URL validation for details.
If authCookie is configured, the auth token is set as a cookie. Otherwise, you need to extract it from the callback.
Handling the Callback
Your frontend callback page receives you after OAuth completes:
// /auth/callback page
// If using authCookie, the token is already in the cookie
// If not, check for token in URL params or make a request
// With auth cookie — just verify it works
const response = await fetch(`${API}/table/users/select`, {
credentials: 'include', // Send cookies
})
const [user] = await response.json()Supported Providers
Built-in presets: Google, GitHub, Discord, LinkedIn. Any OAuth 2.0 provider can be configured manually.
See the OAuth Guide for provider-specific setup.
Google One Tap
If you've configured Google in authProviders, you can use Google One Tap for passwordless login:
<!-- Add to your HTML -->
<div id="g_id_onload"
data-client_id="YOUR_GOOGLE_CLIENT_ID"
data-login_uri="YOUR_API_URL/table/users/auth/google-login"
data-auto_prompt="true">
</div>
<script src="https://accounts.google.com/gsi/client" async></script>The form POSTs to /table/users/auth/google-login with the Google JWT credential. Teenybase verifies it against Google's public keys and returns a teenybase JWT.
Calling Actions
Actions are server-side logic defined in your config. Call them via POST:
const response = await fetch(`${API}/action/increment_counter`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ amount: 5 }),
})
const result = await response.json()
// [[{ id: 1, value: 15 }]] — array of arrays (one per query)See the Actions Guide for how to define actions.
Framework Examples
React
A minimal auth + CRUD hook pattern:
// useTeeny.js
import { useState, useCallback } from 'react'
const API = 'http://localhost:8787/api/v1'
export function useTeeny() {
const [token, setToken] = useState(() => localStorage.getItem('token'))
const headers = useCallback(() => ({
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
}), [token])
const login = async (identity, password) => {
const res = await fetch(`${API}/table/users/auth/login-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity, password }),
})
const data = await res.json()
if (res.ok) {
localStorage.setItem('token', data.token)
localStorage.setItem('refresh_token', data.refresh_token)
setToken(data.token)
}
return data
}
const list = async (table, params = {}) => {
const res = await fetch(`${API}/table/${table}/list`, {
method: 'POST',
headers: headers(),
body: JSON.stringify(params),
})
return res.json()
}
const insert = async (table, values) => {
const res = await fetch(`${API}/table/${table}/insert`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ values }),
})
return res.json()
}
return { token, login, list, insert }
}// App.jsx
function Posts() {
const { list } = useTeeny()
const [posts, setPosts] = useState([])
useEffect(() => {
list('posts', { order: 'created DESC', limit: 10 })
.then(data => setPosts(data.items))
}, [])
return posts.map(p => <div key={p.id}>{p.title}</div>)
}Vue
// composables/useTeeny.js
import { ref, computed } from 'vue'
const API = 'http://localhost:8787/api/v1'
const token = ref(localStorage.getItem('token'))
export function useTeeny() {
const headers = computed(() => ({
'Content-Type': 'application/json',
...(token.value && { 'Authorization': `Bearer ${token.value}` }),
}))
async function login(identity, password) {
const res = await fetch(`${API}/table/users/auth/login-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity, password }),
})
const data = await res.json()
if (res.ok) {
token.value = data.token
localStorage.setItem('token', data.token)
localStorage.setItem('refresh_token', data.refresh_token)
}
return data
}
async function list(table, params = {}) {
const res = await fetch(`${API}/table/${table}/list`, {
method: 'POST',
headers: headers.value,
body: JSON.stringify(params),
})
return res.json()
}
return { token, login, list }
}Vanilla JS
// teeny.js — minimal fetch wrapper
class Teeny {
constructor(baseUrl) {
this.baseUrl = baseUrl
this.token = localStorage.getItem('token')
}
get headers() {
return {
'Content-Type': 'application/json',
...(this.token && { 'Authorization': `Bearer ${this.token}` }),
}
}
async login(identity, password) {
const res = await fetch(`${this.baseUrl}/table/users/auth/login-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity, password }),
})
const data = await res.json()
if (res.ok) {
this.token = data.token
localStorage.setItem('token', data.token)
localStorage.setItem('refresh_token', data.refresh_token)
}
return data
}
async list(table, params = {}) {
const res = await fetch(`${this.baseUrl}/table/${table}/list`, {
method: 'POST', headers: this.headers,
body: JSON.stringify(params),
})
return res.json()
}
async insert(table, values) {
const res = await fetch(`${this.baseUrl}/table/${table}/insert`, {
method: 'POST', headers: this.headers,
body: JSON.stringify({ values }),
})
return res.json()
}
async edit(table, id, values) {
const res = await fetch(`${this.baseUrl}/table/${table}/edit/${id}`, {
method: 'POST', headers: this.headers,
body: JSON.stringify({ setValues: values }),
})
return res.json()
}
async remove(table, where) {
const res = await fetch(`${this.baseUrl}/table/${table}/delete`, {
method: 'POST', headers: this.headers,
body: JSON.stringify({ where }),
})
return res.json()
}
}
// Usage
const tb = new Teeny('http://localhost:8787/api/v1')
await tb.login('alice@example.com', 'password')
const { items } = await tb.list('posts', { limit: 10, order: 'created DESC' })Tips
- SPA clients: Store tokens in
localStorageand pass viaAuthorization: Bearerheader. - SSR apps: Set an
httpOnlycookie server-side after login, and configureauthCookieso teenybase reads it on each request. See the notes-sample for a working example. - Mobile apps: Use secure storage (Keychain/Keystore). Ignore
Set-Cookieheaders -- use Bearer tokens. - Build a fetch wrapper that auto-attaches the
Authorizationheader and handles 401 -> refresh -> retry. - Use the OpenAPI spec at
/api/v1/docto auto-generate typed clients with tools likeopenapi-typescript-codegen. - Swagger UI is available at
/api/v1/doc/ui-- use it to explore and test endpoints interactively.