Project Team Onboarding
March 5, 2026 Β· View on GitHub
Complete guide for new team members
Note: This document was AI-generated and has not been fully reviewed. It may contain errors or outdated information. Please take it with a grain of salt and verify against the codebase when in doubt.
π Quick Start (5 Minutes)
# Create new project
npx create-ts-liveview@latest my-app
# Setup
cd my-app
./scripts/init.sh
# Start development server
npm start
# Visit http://localhost:8100
Before You Start
The project uses WebSocket for real-time updates. The client keeps a connection open to the server. A few familiar patterns behave differently here:
alert()andconfirm()block the JS runtime and can disconnect the WebSocket β use DOM updates instead- Client scripts run in global scope; elements with
idbecome global variables - JSX has a specific AST structure β use
mapArray()or[].map()wrap for arrays (see Critical Patterns)
π What is this project?
This starter template helps you build fast, interactive web applications with:
- Server-side rendering with minimal client JavaScript (2.3KB gzipped)
- Real-time updates via WebSocket
- JSX support without Virtual DOM
- Type-safe routing with full TypeScript support
- Progressive enhancement - works without JS, enhanced with JS
Why Use It?
- 102x-45x smaller than socket.io alone
- Server-first architecture reduces client complexity
- Real-time interactivity without heavy frontend frameworks
- Fast initial page load with HTML streaming
- Built on Express.js for Node.js ecosystem
β οΈ Critical Patterns (Must Read!)
1. Array Rendering - Use mapArray() or Wrap
β WRONG:
{products.map(product => <div>{product.name}</div>)}
β CORRECT Option 1:
import { mapArray } from '../components/fragment.js'
{mapArray(products, product => <div>{product.name}</div>)}
β CORRECT Option 2:
{[products.map(product => <div>{product.name}</div>)]}
Note: The key attribute (e.g. key={item.id}) is optional. Use it when
child components need to track identity (e.g. SingleFieldForm), but it is not
required for simple list rendering.
2. Server Communication - Use emit()
Use the global emit(url, ...args) function (defined in client/index.ts) for
server communication. The server routes messages by URL.
3. Scripts - Define at Top Level
β WRONG:
function Page() {
return (
<div>
<button onclick="doSomething()">Click</button>
<script>{`function doSomething() { ... }`}</script>
</div>
)
}
β CORRECT:
import Script from '../components/script.js'
let script = Script(/* js */ `
function doSomething() {
console.log('clicked')
}
`)
function Page() {
return <>
{script}
<div>
<button onclick="doSomething()">Click</button>
</div>
</>
}
4. Styles - Same Pattern
import Style from '../components/style.js'
let style = Style(/* css */ `
#my-page {
padding: 1rem;
}
`)
function Page() {
return <>
{style}
<div id="my-page">Content</div>
</>
}
π Project Structure
my-app/
βββ server/
β βββ app/
β β βββ pages/ # Page components
β β βββ components/ # Reusable components
β β βββ routes.tsx # Route definitions (routeDict from page .routes)
β β βββ app.tsx # App layout
β β βββ express.ts # Express app setup
β βββ index.ts # Server entry point
βββ db/ # Database schema & migrations
β βββ erd.txt # Schema (ERD format)
β βββ proxy.ts # Database proxy (typed, auto-generated from erd.txt)
β βββ migrations/
βββ docs/ # Developer guide, AI agent guidelines, examples
βββ client/ # Client bundle source
βββ build/ # Built client bundle
βββ public/ # Static assets (css, js, images)
βββ scripts/ # Setup & deployment scripts (init.sh, new-task.sh, deploy.sh, ...)
βββ tasks/ # Optional: task backlog & planning (.md files; created by new-task.sh or manually)
βββ package.json
π― Creating Your First Page
Step 1: Create Page Component
File: server/app/pages/hello.tsx
import { o } from '../jsx/jsx.js'
import Style from '../components/style.js'
import Script from '../components/script.js'
let style = Style(/* css */ `
#hello-page {
padding: 2rem;
text-align: center;
}
`)
let script = Script(/* js */ `
function sayHello() {
greeting.textContent = 'Hello from the app!'
}
`)
export default function HelloPage() {
return <>
{style}
<div id="hello-page">
<h1>Hello!</h1>
<span id="greeting"></span>
<button onclick="sayHello()">Click Me</button>
</div>
{script}
</>
}
Step 2: Add Route
File: server/app/routes.tsx
import HelloPage from './pages/hello.js'
let routes: Routes = {
// ... existing routes
'/hello': {
title: 'Hello',
description: 'My first page',
resolve: () => ({
title: 'Hello World',
node: <HelloPage />
})
}
}
Step 3: Test
Visit http://localhost:8100/hello - Done! π
π Real-Time Updates
How It Works
- Server sends initial HTML
- WebSocket connects after page load
- User interactions β WebSocket β Server
- Server processes β generates updates β sends to client
- Client applies updates to DOM
Example: Live Task List with Database
import { o } from '../jsx/jsx.js'
import Script from '../components/script.js'
import { Context } from '../context.js'
import { proxy } from '../../db/proxy.js'
import { mapArray } from '../components/fragment.js'
import { pick } from 'better-sqlite3-proxy'
let script = Script(/* js */ `
function addTask() {
let input = document.getElementById('task-input')
let text = input.value.trim()
if (!text) return
emit('/tasks/add', text)
input.value = ''
}
function toggleTask(taskId) {
emit('/tasks/toggle', taskId)
}
`)
export function TaskListPage() {
// Query tasks from database
let tasks = pick(proxy.task, ['id', 'text', 'completed'])
return <>
{script}
<div id="task-list">
<h1>My Tasks</h1>
<div class="add-task">
<input
type="text"
id="task-input"
placeholder="What needs to be done?"
onkeypress="if(event.key==='Enter') addTask()"
/>
<button onclick="addTask()">Add Task</button>
</div>
<ul>
{mapArray(tasks, task => (
<li key={task.id} class={task.completed ? 'completed' : ''}>
<input
type="checkbox"
checked={task.completed}
onclick={`toggleTask(${task.id})`}
/>
{task.text}
</li>
))}
</ul>
</div>
</>
}
// Server handler for adding task
export function handleAddTask(data: { text: string }, context: Context) {
let userId = context.session.get('userId')
// Save to database
proxy.task.push({
text: data.text,
completed: false,
user_id: userId,
created_at: new Date().toISOString()
})
return <TaskListPage />
}
// Server handler for toggling task
export function handleToggleTask(data: { taskId: number }, context: Context) {
let task = proxy.task.find(t => t.id === data.taskId)
if (task) {
task.completed = !task.completed
}
return <TaskListPage />
}
πΊοΈ Routing Patterns
Static Route
Static routes can use either node directly or resolve when you need to return other fields (e.g. title):
// Option 1: node directly
'/about': {
title: 'About',
node: <AboutPage />
}
// Option 2: resolve (when you need dynamic title, etc.)
'/about': {
title: 'About',
resolve: () => ({ node: <AboutPage /> })
}
Dynamic Route
Use context.routerMatch?.params for route params (e.g. :id in /user/:id):
'/user/:id': {
title: 'User Profile',
resolve: (context) => ({
node: <UserProfile userId={context.routerMatch?.params.id} />
})
}
Protected Route
context.session is available in WebSocket/event handlers. For HTTP route
resolution (initial page load), use context.req and your session middleware
(e.g. context.req.session?.userId):
'/dashboard': {
resolve: (context) => {
let userId = context.type === 'ws'
? context.session.get('userId')
: context.req?.session?.userId
if (!userId) return { redirect: '/login' }
return { node: <Dashboard userId={userId} /> }
}
}
404 Catch-All
'*': {
title: 'Page Not Found',
resolve: () => ({ node: <NotFoundPage /> })
}
πΎ Database (better-sqlite3-proxy)
The project includes SQLite with better-sqlite3-proxy for type-safe database access. This setup is ready out of the box β no ORM or other database needed. You can modify to use other databases if you prefer.
Use the proxy for type-safe CRUD and basic searching (find, filter, pick). For complex queries (joins, ORDER BY, LIMIT), use prepared statements on the underlying db.
Avoid: Do not use array methods on proxy tables (e.g. proxy.label.filter(...), proxy.user.find(...)). The proxy is backed by the DB table; .filter / .find would iterate all rows in memory. Use the helpers from better-sqlite3-proxy instead: filter(proxy.label, { project_id }), find(proxy.user, { id: userId }).
Setup
File: db/proxy.ts (auto-generated from erd.txt)
import { proxySchema } from 'better-sqlite3-proxy'
import { db } from './db'
export type User = {
id?: null | number
name: string
email: string
created_at?: string
}
export let proxy = proxySchema<{
user: User
// ... other tables
}>({
db,
tableFields: {
user: [],
// ... other tables
},
})
Query Data
import { proxy } from '../../db/proxy.js'
import { filter, find } from 'better-sqlite3-proxy'
// Get all users
let users = proxy.user
// Find one user
let user = find(proxy.user, { id: userId })
// Filter users (DB-backed; does not loop all rows in JS)
let activeUsers = filter(proxy.user, { active: true })
// β Avoid: proxy.user.filter(u => u.active) β iterates entire table in memory
// Custom query with pick
import { pick } from 'better-sqlite3-proxy'
let userNames = pick(proxy.user, ['id', 'name'])
// Returns: [{ id: 1, name: 'John' }, ...]
Insert/Update/Delete
import { proxy } from '../../db/proxy.js'
// Insert
let newUserId = proxy.user.push({
name: 'John',
email: 'john@example.com',
})
// Update
let user = find(proxy.user, { id: userId })
if (user) {
user.name = 'Jane'
user.email = 'jane@example.com'
}
// Delete
let user = find(proxy.user, { id: userId })
if (user) {
proxy.user.splice(proxy.user.indexOf(user), 1)
}
Example: User List Page
import { o } from '../jsx/jsx.js'
import { proxy } from '../../db/proxy.js'
import { mapArray } from '../components/fragment.js'
import { pick } from 'better-sqlite3-proxy'
export function UsersPage() {
// Get all users (select only needed fields)
let users = pick(proxy.user, ['id', 'name', 'email'])
return (
<div id="users-page">
<h1>Users</h1>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{mapArray(users, user => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
π¨ Complete Page Example
Same pattern as User List above: pick() for fields, mapArray() for rendering,
emit(url, data) for actions. See Blog Posts Manager
for a full CRUD example.
β Best Practices
DO:
- β
Use
mapArray()or[array.map(...)]for arrays - β
Define scripts/styles at top level with
Script()andStyle() - β
Use
emit(url, data)for server communication (defined in client/index.ts) - β
Use elements with
idas global variables (e.g.greetingforid="greeting") - β
Use fragments
<>...</>for multiple top-level elements - β Keep state on server when possible
- β Validate all input on server
- β Use TypeScript types for safety
- β
Use
pick()to select only needed database fields - β Use descriptive component names
DON'T:
- β Use
.map()directly in JSX without wrapping (plain array doesn't match JSX AST; see Critical Patterns above) - β Put
<script>or<style>inline in component JSX (raw<script>works butScriptcomponent preferred for minification) - β Use
alert()β the project keeps a WebSocket open for real-time updates; alert() blocks the JS runtime and can disconnect it. Use DOM updates (e.g. span.textContent = 'Error') or a toast component instead - β Trust client input (security: always validate on server)
- β Store large objects in session (memory, serialization)
- β Query all fields when you only need some (use pick() for performance)
- β Forget error handling (validate, catch, show user-friendly messages)
π Common Issues & Fixes
WebSocket Not Connecting
// Check in browser console
console.log('WS state:', ws.readyState)
// 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
// Restart server
npm start
JSX Not Rendering
// Ensure correct imports
import { o } from '../jsx/jsx.js'
// Check for unclosed tags
Database Schema Changes
Reviewing migrations before applying helps catch issues.
# Update db/erd.txt, then from db/ folder:
cd db
npm run plan # Generate migration script
# Review the migration in migrations/ directory
npm run update # Apply migration + regenerate proxy
# If migration needs changes after running:
npx knex migrate:down # Rollback last migration
# Edit the migration file, then run npm run update again
For simple changes, you can use npm run dev to run plan, migrate, and update
in one step (review is still recommended but less likely to need edits). For
non-trivial schema changes, always use the manual workflow above and review
before applying.
Hot Reload Not Working
# Restart dev server
npm start
# Clear browser cache
Ctrl+Shift+R (Windows/Linux)
Cmd+Shift+R (Mac)
π Learning Goals
Progress through these at your own pace:
- Understand the project structure
- Create 2-3 simple pages
- Implement routing between pages
- Build a form with validation
- Query and display database data
- Implement real-time features
- Create reusable components
- Add authentication
- Deploy to production
π Essential Imports
See Import Patterns in Quick Reference below.
π Resources
Official
- GitHub: github.com/beenotung/ts-liveview
- Website: liveviews.cc
- npm: npmjs.com/package/ts-liveview
Examples
- Thermostat Demo: liveviews.cc/thermostat
- Form Demo: liveviews.cc/form
- Chat Room: liveviews.cc/chatroom
- User Agents: liveviews.cc/user-agents
Related
- TypeScript: typescriptlang.org/docs
- Express.js: expressjs.com
- better-sqlite3: github.com/WiseLibs/better-sqlite3
- better-sqlite3-proxy: github.com/beenotung/better-sqlite3-proxy
π Next Steps
-
Clone/Create Project
npx create-ts-liveview@latest my-first-app cd my-first-app ./scripts/init.sh npm start -
Explore Code
server/app/pages/home.tsxserver/app/routes.tsxdb/proxy.ts(generated from erd.txt)
-
Build Something
- User list from database
- Task tracker with real-time updates
- Contact form saving to database
-
Review Examples
- Study demo pages in the repo
- Check official examples at liveviews.cc
π‘ Key Takeaways
- Server-first: Logic lives on the server
- Minimal client: 2.3KB client runtime
- Real-time: WebSocket for live updates
- Type-safe: Full TypeScript support + proxy types
- Fast: HTML streaming, efficient updates
- Progressive: Works without JS, better with JS
Document Version: 3.0 Last Updated: February 2026 Based on: ts-liveview template + better-sqlite3-proxy patterns
π Quick Reference
Commands Cheat Sheet
# Setup
npx create-ts-liveview@latest my-app
./scripts/init.sh
# Development
npm start # Start dev server
npm run build # Build for production
npm run format # Format code
npm run fix # Add .js extensions
# Database (from db/ folder)
cd db && npm run plan # Generate migration from erd.txt
# Review migrations/, then:
npm run update # Apply migration + gen-proxy
# Deploy
./scripts/deploy.sh # Deploy to server
Import Patterns
// Core (paths from server/app/pages/)
import { o } from '../jsx/jsx.js'
import Style from '../components/style.js'
import Script from '../components/script.js'
import { mapArray } from '../components/fragment.js'
// Context
import { Context } from '../context.js'
// Database
import { proxy } from '../../db/proxy.js'
import { find, filter, pick } from 'better-sqlite3-proxy'
// Routing (Routes type from ../routes.js when in pages/)
import { Routes } from '../routes.js'
Common Patterns Quick Copy
Basic Page
(See Creating Your First Page for full example.)
import { o } from '../jsx/jsx.js'
export default function MyPage() {
return (
<div id="my-page">
<h1>My Page</h1>
</div>
)
}
Page with Style & Script
(See Creating Your First Page.)
import { o } from '../jsx/jsx.js'
import Style from '../components/style.js'
import Script from '../components/script.js'
let style = Style(/* css */ `
#my-page { padding: 1rem; }
`)
let script = Script(/* js */ `
function handleClick() {
output.textContent = 'Clicked!'
}
`)
export default function MyPage() {
return <>
{style}
<div id="my-page">
<span id="output"></span>
<button onclick="handleClick()">Click</button>
</div>
{script}
</>
}
List from Database
import { o } from '../jsx/jsx.js'
import { proxy } from '../../db/proxy.js'
import { mapArray } from '../components/fragment.js'
import { pick } from 'better-sqlite3-proxy'
export default function ListPage() {
let items = pick(proxy.items, ['id', 'name'])
return (
<div>
<ul>
{mapArray(items, item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
)
}
Form Handler
// In routes.tsx or api handler
app.post('/api/contact', (req, res) => {
let { name, email, message } = req.body
// Validate
if (!name || !email || !message) {
return res.status(400).json({ error: 'Missing fields' })
}
// Save to database
proxy.contact.push({ name, email, message })
res.json({ success: true })
})
Database Quick Reference
// Get all
let users = proxy.user
// Find one
let user = find(proxy.user, { id: 123 })
// Filter
let active = filter(proxy.user, { active: true })
// Select specific fields
let names = pick(proxy.user, ['id', 'name'])
// Insert
let newId = proxy.user.push({ name: 'John', email: 'j@ex.com' })
// Update
let user = find(proxy.user, { id: 123 })
if (user) {
user.name = 'Jane'
}
// Delete
let user = find(proxy.user, { id: 123 })
if (user) {
proxy.user.splice(proxy.user.indexOf(user), 1)
}
Routes Quick Reference
let routes: Routes = {
// Static
'/': {
title: 'Home',
resolve: () => ({ node: <HomePage /> })
},
// Dynamic (use routerMatch.params for route params)
'/post/:id': {
resolve: (ctx) => ({ node: <Post id={ctx.routerMatch?.params.id} /> })
},
// Protected (session from ctx.session for ws, ctx.req?.session for express)
'/admin': {
resolve: (ctx) => {
let admin = ctx.type === 'ws'
? ctx.session.get('admin')
: ctx.req?.session?.admin
if (!admin) return { redirect: '/login' }
return { node: <AdminPage /> }
}
},
// 404
'*': {
resolve: () => ({ node: <NotFound /> })
}
}
WebSocket Communication
// Client to Server
let script = Script(/* js */ `
function sendData(data) {
emit('/my-event', data)
}
`)
// Server Handler (in your event handler file)
export function handleMyEvent(data, context) {
// Process data
context.session.set('someValue', data.value)
// Return updated component
return <UpdatedComponent />
}
DO / DON'T Summary
| β DO | β DON'T |
|---|---|
{mapArray(arr, ...)} | {arr.map(...)} |
{[arr.map(...)]} | Direct .map() |
let script = Script(...) | Inline <script> |
let style = Style(...) | Inline <style> |
| Top-level definitions | Inline definitions |
proxy.user.push({...}) | db.insert(...) |
find(proxy.user, {...}) | await db.query... |
| Server validation | Client-only validation |
pick() for fields | Query all fields |
π― Common Tasks
Add a New Page
- Create
server/app/pages/my-page.tsx - Add route in
server/app/routes.tsx - Add navigation link if needed
Add Database Table
- Edit
db/erd.txt(see db/README.md for schema format) - Run
cd db && npm run plan, review the migration inmigrations/, thennpm run update- If migration needs changes after running:
npx knex migrate:down, edit migration, thennpm run update
- If migration needs changes after running:
- Import and use
proxy.tableName
Add Real-Time Feature
- Use
emit(url, data)in client script (defined in client/index.ts) - Add route handler for the URL in routes
- Return updated component from handler
Wire emit() to Route Handlers
When the client calls emit('/posts/create', data), the server routes by URL.
Add a route whose resolve invokes your handler:
// In your page file, export routes and add to routeDict in routes.tsx:
let routes = {
'/posts': { title: 'Posts', node: <BlogPostsPage /> },
'/posts/create': {
resolve: (ctx) => {
let data = ctx.args?.[0]
return handleCreatePost(data, ctx)
}
},
}
export default { routes }
Then in server/app/routes.tsx: add ...BlogPosts.routes (or your module name) to routeDict. If you create the page with ./scripts/new-route.sh, it inserts this line and the import automatically.
Deploy
- Update
scripts/config - Run
./scripts/deploy.sh - Server deploys with pm2
π Getting Help
- Check Examples: Review demo pages at liveviews.cc
- Read Source: Look at existing pages in
server/app/pages/ - GitHub Issues: github.com/beenotung/ts-liveview/issues
- Official Docs: github.com/beenotung/ts-liveview
πΌ Complete Real-World Example: Blog Posts Manager
See Blog Posts Manager example for a complete CRUD example with database, real-time updates, session management, and route wiring.
That's everything you need to get started! Welcome to the team! π