Introduction
In this tutorial, we'll build a complete CRUD (Create, Read, Update, Delete) application using modern, high-performance JavaScript tools. We'll use SolidJS for the frontend, Hono for the backend API, Bun as our runtime, and PostgreSQL as our database. The best part? Everything will be fully type-safe from frontend to backend using Hono RPC.
Why These Technologies?
SolidJS vs React and Other Frameworks
SolidJS is a reactive JavaScript framework that compiles away, meaning there's no virtual DOM overhead.
Performance Benchmarks:
| Framework | Startup Time | Update Speed | Memory Usage | Bundle Size |
|---|---|---|---|---|
| SolidJS | 1.0x (baseline) | 1.0x | 1.0x | ~7kb |
| React | 2.5x slower | 3.2x slower | 2.8x more | ~42kb |
| Vue 3 | 1.8x slower | 2.1x slower | 1.9x more | ~34kb |
| Svelte | 1.2x slower | 1.4x slower | 1.1x more | ~12kb |
Key Advantages of SolidJS:
- No virtual DOM - updates are surgically precise
- True reactivity using signals
- Faster than React in almost all benchmarks
- Smaller bundle sizes
- Familiar JSX syntax for React developers
When to choose SolidJS:
- Performance is critical
- You want fine-grained reactivity
- You're building interactive dashboards or data-heavy apps
- You want React-like syntax without React overhead
Hono vs Express and Other Backend Frameworks
Hono is an ultrafast web framework designed for edge computing but works great everywhere.
Performance Benchmarks:
| Framework | Requests/sec | Latency (avg) | Memory | Router Speed |
|---|---|---|---|---|
| Hono | 50,000+ | 0.5ms | Low | Fastest |
| Fastify | 45,000+ | 0.7ms | Medium | Fast |
| Express | 15,000+ | 2.5ms | High | Slow |
| Koa | 18,000+ | 2.2ms | Medium | Medium |
Key Advantages of Hono:
- Extremely fast routing using RegExpRouter
- Built-in TypeScript support
- Works on Node.js, Bun, Deno, Cloudflare Workers, and more
- Middleware ecosystem similar to Express
- RPC client for end-to-end type safety
When to choose Hono:
- You need maximum performance
- You want modern TypeScript-first development
- You're deploying to edge environments
- You want type-safe API clients
Why Bun?
Bun is an all-in-one JavaScript runtime that's significantly faster than Node.js.
Performance Comparison:
| Operation | Bun | Node.js 20 | Difference |
|---|---|---|---|
| Server requests/sec | 140,000+ | 60,000+ | 2.3x faster |
| Package install | 2s | 15s | 7.5x faster |
| Script execution | 0.8ms | 2.5ms | 3x faster |
Prerequisites
- Basic JavaScript/TypeScript knowledge
- PostgreSQL installed locally
- Code editor (VS Code recommended)
Project Setup
Step 1: Install Bun
# macOS/Linux
curl -fsSL https://bun.sh/install | bash
# Windows (use WSL)
# Or download from bun.sh
Verify installation:
bun --version
Step 2: Create Project Structure
# Create project folder
mkdir solidjs-hono-crud
cd solidjs-hono-crud
# Create separate folders for frontend and backend
mkdir client server
Backend Setup with Hono
Step 3: Initialize Backend
cd server
bun init -y
Step 4: Install Backend Dependencies
bun add hono
bun add postgres
bun add @hono/node-server
Step 5: Set Up PostgreSQL Database
Connect to PostgreSQL and create a database:
CREATE DATABASE todo_app;
\c todo_app
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Step 6: Create Database Connection
Create server/db.ts:
import postgres from 'postgres';
// Database connection
const sql = postgres({
host: 'localhost',
port: 5432,
database: 'todo_app',
username: 'your_username', // Change this
password: 'your_password', // Change this
});
export default sql;
What's happening here:
- We're using the
postgreslibrary which is fast and modern - The connection configuration points to our local PostgreSQL instance
- This
sqlobject will be used to run queries
Step 7: Create API Routes with Hono
Create server/index.ts:
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import sql from './db';
// Define the Todo type
type Todo = {
id: number;
title: string;
description: string | null;
completed: boolean;
created_at: Date;
};
// Create Hono app
const app = new Hono();
// Enable CORS for frontend
app.use('/*', cors());
// Create API routes
const api = new Hono()
// Get all todos
.get('/todos', async (c) => {
const todos = await sql<Todo[]>`
SELECT * FROM todos ORDER BY created_at DESC
`;
return c.json(todos);
})
// Get single todo
.get('/todos/:id', async (c) => {
const id = parseInt(c.req.param('id'));
const todos = await sql<Todo[]>`
SELECT * FROM todos WHERE id = ${id}
`;
if (todos.length === 0) {
return c.json({ error: 'Todo not found' }, 404);
}
return c.json(todos[0]);
})
// Create todo
.post('/todos', async (c) => {
const body = await c.req.json();
const newTodos = await sql<Todo[]>`
INSERT INTO todos (title, description)
VALUES (${body.title}, ${body.description})
RETURNING *
`;
return c.json(newTodos[0], 201);
})
// Update todo
.put('/todos/:id', async (c) => {
const id = parseInt(c.req.param('id'));
const body = await c.req.json();
const updated = await sql<Todo[]>`
UPDATE todos
SET
title = ${body.title},
description = ${body.description},
completed = ${body.completed}
WHERE id = ${id}
RETURNING *
`;
if (updated.length === 0) {
return c.json({ error: 'Todo not found' }, 404);
}
return c.json(updated[0]);
})
// Delete todo
.delete('/todos/:id', async (c) => {
const id = parseInt(c.req.param('id'));
const deleted = await sql<Todo[]>`
DELETE FROM todos WHERE id = ${id} RETURNING *
`;
if (deleted.length === 0) {
return c.json({ error: 'Todo not found' }, 404);
}
return c.json({ message: 'Todo deleted' });
});
// Mount API routes
app.route('/api', api);
// Export the app type for RPC client
export type AppType = typeof api;
// Start server
export default {
port: 3000,
fetch: app.fetch,
};
console.log('Server running on http://localhost:3000');
Understanding this code:
- Hono Instance: We create a Hono app instance
- CORS Middleware: Allows our frontend to make requests
-
Type Safety: We define a
Todotype that matches our database schema - SQL Queries: Using tagged template literals for safe SQL queries
- REST Endpoints: Standard CRUD operations
- Export AppType: This is the magic for RPC - we export the type of our API
- Error Handling: Returning proper HTTP status codes
Step 8: Run the Backend
bun run index.ts
Your API should now be running on http://localhost:3000!
Frontend Setup with SolidJS
Step 9: Create SolidJS App
cd ../client
bunx degit solidjs/templates/ts my-app
mv my-app/* .
mv my-app/.* . 2>/dev/null || true
rm -rf my-app
Step 10: Install Frontend Dependencies
bun install
bun add @hono/client
What is @hono/client?
This is the RPC client that gives us end-to-end type safety. It knows about all our API routes and their types automatically!
Step 11: Create API Client
Create client/src/api.ts:
import { hc } from '@hono/client';
import type { AppType } from '../../server/index';
// Create type-safe client
export const client = hc<AppType>('http://localhost:3000/api');
The Magic of Hono RPC:
-
hc<AppType>creates a client that knows all your API routes - TypeScript will autocomplete all endpoints
- You get compile-time errors if you use wrong types
- No need to manually type API responses!
Step 12: Understanding SolidJS Basics
Before we build the UI, let's understand SolidJS core concepts:
1. Signals - The Heart of Reactivity
import { createSignal } from 'solid-js';
// Create a signal (reactive state)
const [count, setCount] = createSignal(0);
// Read the value - MUST call as a function
console.log(count()); // 0
// Update the value
setCount(1);
setCount(c => c + 1); // Using updater function
2. Effects - React to Changes
import { createEffect } from 'solid-js';
createEffect(() => {
// This runs whenever count() changes
console.log('Count is now:', count());
});
3. Stores - Complex State Management
import { createStore } from 'solid-js/store';
const [todos, setTodos] = createStore([
{ id: 1, title: 'Learn SolidJS' }
]);
// Update nested values
setTodos(0, 'title', 'Master SolidJS');
// Add item
setTodos([...todos, newTodo]);
Key Differences from React:
| Concept | React | SolidJS |
|---|---|---|
| State | const [x, setX] = useState() |
const [x, setX] = createSignal() |
| Reading state |
x (direct) |
x() (function call) |
| Re-renders | Component re-runs | Only affected parts update |
| Effects | useEffect |
createEffect |
| Memo | useMemo |
createMemo |
Step 13: Build the Todo Component
Create client/src/App.tsx:
import { createSignal, createResource, For, Show } from 'solid-js';
import { createStore } from 'solid-js/store';
import { client } from './api';
import './App.css';
type Todo = {
id: number;
title: string;
description: string | null;
completed: boolean;
created_at: Date;
};
function App() {
// Form state using signals
const [title, setTitle] = createSignal('');
const [description, setDescription] = createSignal('');
const [editingId, setEditingId] = createSignal<number | null>(null);
// Fetch todos using createResource
// This automatically handles loading and error states
const [todos, { mutate, refetch }] = createResource(fetchTodos);
async function fetchTodos(): Promise<Todo[]> {
const response = await client.todos.$get();
const data = await response.json();
return data as Todo[];
}
// Create new todo
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title().trim()) return;
const editing = editingId();
if (editing !== null) {
// Update existing todo
await client.todos[':id'].$put({
param: { id: editing.toString() },
json: {
title: title(),
description: description(),
completed: false,
},
});
setEditingId(null);
} else {
// Create new todo
await client.todos.$post({
json: {
title: title(),
description: description(),
},
});
}
// Reset form
setTitle('');
setDescription('');
// Refetch todos
refetch();
}
// Delete todo
async function deleteTodo(id: number) {
await client.todos[':id'].$delete({
param: { id: id.toString() },
});
refetch();
}
// Toggle completed
async function toggleCompleted(todo: Todo) {
await client.todos[':id'].$put({
param: { id: todo.id.toString() },
json: {
title: todo.title,
description: todo.description,
completed: !todo.completed,
},
});
refetch();
}
// Edit todo
function editTodo(todo: Todo) {
setTitle(todo.title);
setDescription(todo.description || '');
setEditingId(todo.id);
}
return (
<div class="container">
<h1>SolidJS Todo App</h1>
{/* Form */}
<form onSubmit={handleSubmit} class="todo-form">
<input
type="text"
placeholder="Title"
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
class="input"
/>
<textarea
placeholder="Description"
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
class="input"
/>
<button type="submit" class="btn btn-primary">
<Show when={editingId() !== null} fallback="Add Todo">
Update Todo
</Show>
</button>
<Show when={editingId() !== null}>
<button
type="button"
onClick={() => {
setEditingId(null);
setTitle('');
setDescription('');
}}
class="btn btn-secondary"
>
Cancel
</button>
</Show>
</form>
{/* Todos List */}
<Show
when={!todos.loading}
fallback={<div class="loading">Loading todos...</div>}
>
<div class="todos-list">
<For each={todos()}>
{(todo) => (
<div class="todo-item" classList={{ completed: todo.completed }}>
<div class="todo-content">
<h3 onClick={() => toggleCompleted(todo)} class="todo-title">
{todo.title}
</h3>
<Show when={todo.description}>
<p class="todo-description">{todo.description}</p>
</Show>
</div>
<div class="todo-actions">
<button
onClick={() => editTodo(todo)}
class="btn btn-small"
>
Edit
</button>
<button
onClick={() => deleteTodo(todo.id)}
class="btn btn-small btn-danger"
>
Delete
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
);
}
export default App;
Understanding SolidJS Components:
- createSignal: Reactive state that updates UI when changed
- createResource: Async data fetching with built-in loading/error states
- For: Efficiently renders lists (like map in React but optimized)
- Show: Conditional rendering (like && or ternary in React)
- classList: Reactive class binding
Type Safety in Action:
- Notice how we get autocomplete on
client.todos.$get() - TypeScript knows the exact shape of our API responses
- We get errors if we pass wrong parameter types
Step 14: Add Styling
Create client/src/App.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 2.5rem;
}
.todo-form {
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 30px;
padding-bottom: 30px;
border-bottom: 2px solid #f0f0f0;
}
.input {
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
.input:focus {
outline: none;
border-color: #667eea;
}
textarea.input {
min-height: 80px;
resize: vertical;
font-family: inherit;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-small {
padding: 6px 12px;
font-size: 14px;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.loading {
text-align: center;
padding: 40px;
color: #667eea;
font-size: 1.2rem;
}
.todos-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.todo-item {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.3s;
}
.todo-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateX(4px);
}
.todo-item.completed {
opacity: 0.6;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
color: #6c757d;
}
.todo-content {
flex: 1;
}
.todo-title {
font-size: 1.25rem;
color: #333;
margin-bottom: 8px;
cursor: pointer;
}
.todo-description {
color: #6c757d;
font-size: 0.95rem;
line-height: 1.5;
}
.todo-actions {
display: flex;
gap: 10px;
}
@media (max-width: 768px) {
.container {
padding: 20px;
}
h1 {
font-size: 2rem;
}
.todo-item {
flex-direction: column;
align-items: flex-start;
}
.todo-actions {
margin-top: 12px;
width: 100%;
}
.btn-small {
flex: 1;
}
}
Step 15: Run the Frontend
bun run dev
Your app should now be running on http://localhost:5173!
Testing the Application
- Create a Todo: Fill in the form and click "Add Todo"
- Mark as Complete: Click on the todo title
- Edit a Todo: Click "Edit", modify the text, and click "Update"
- Delete a Todo: Click "Delete"
Advanced SolidJS Concepts
createMemo - Computed Values
import { createMemo } from 'solid-js';
// Computed value that only recalculates when todos change
const completedCount = createMemo(() => {
return todos()?.filter(t => t.completed).length || 0;
});
// Use in template
<div>Completed: {completedCount()} / {todos()?.length || 0}</div>
createEffect - Side Effects
import { createEffect } from 'solid-js';
// Run code when todos change
createEffect(() => {
const todoList = todos();
console.log('Todos updated:', todoList);
// Save to localStorage
if (todoList) {
localStorage.setItem('todos', JSON.stringify(todoList));
}
});
Error Boundaries
import { ErrorBoundary } from 'solid-js';
<ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}>
<App />
</ErrorBoundary>
Production Deployment
Building for Production
Backend:
cd server
bun build index.ts --outfile=dist/server.js --target=bun
Frontend:
cd client
bun run build
Environment Variables
Create server/.env:
DATABASE_URL=postgresql://user:password@localhost:5432/todo_app
PORT=3000
Update server/db.ts:
import postgres from 'postgres';
const sql = postgres(process.env.DATABASE_URL!);
export default sql;
Docker Deployment
Create Dockerfile:
FROM oven/bun:1 as builder
WORKDIR /app
# Build backend
COPY server/package.json server/bun.lockb ./server/
WORKDIR /app/server
RUN bun install
COPY server/ .
RUN bun build index.ts --outfile=dist/server.js
# Build frontend
WORKDIR /app/client
COPY client/package.json client/bun.lockb ./
RUN bun install
COPY client/ .
RUN bun run build
# Production image
FROM oven/bun:1-slim
WORKDIR /app
COPY --from=builder /app/server/dist ./server
COPY --from=builder /app/client/dist ./client/dist
WORKDIR /app/server
EXPOSE 3000
CMD ["bun", "server.js"]
Performance Comparison Summary
Why This Stack is Fast
- Bun Runtime: 2-3x faster than Node.js
- SolidJS: No virtual DOM overhead, fine-grained reactivity
- Hono: Extremely fast routing and middleware
- Type Safety: Catch errors at compile time, not runtime
Real-World Benefits
- Faster Development: Type safety means fewer bugs
- Better DX: Autocomplete everywhere
- Smaller Bundles: SolidJS compiles to minimal code
- Lower Server Costs: Hono handles more requests with less resources
Conclusion
You've now built a complete full-stack application with:
- ✅ Type-safe API calls from frontend to backend
- ✅ Fast, reactive UI with SolidJS
- ✅ High-performance backend with Hono
- ✅ Modern runtime with Bun
- ✅ PostgreSQL database integration
- ✅ Full CRUD operations
Next Steps
- Add authentication with JWT
- Implement pagination for large todo lists
- Add real-time updates with WebSockets
- Deploy to production (DigitalOcean, Railway, Fly.io)
- Add tests with Vitest
Top comments (1)
This was such a solid (no pun intended 😅) breakdown of a modern full-stack stack that actually feels cohesive instead of just “yet another tech combo”.
What really hit me reading this:
The way you framed SolidJS vs React using actual numbers (startup, memory, bundle size) made the trade-offs click in a very practical way. It’s not just “Solid is fast”, it’s why it’s fast and when that matters (dashboards, data-heavy UIs, etc.).
The Hono + Bun + Postgres combo feels insanely intentional. Hono for edge-capable routing and type-safe APIs, Bun for raw speed and DX, Postgres as the reliable backbone — that’s the kind of stack I can actually imagine running in production without feeling like I’m gambling on something too experimental.
The Hono RPC + AppType bit is probably my favourite part. That single hc line quietly gives you end-to-end type safety, and you made it feel very natural in the flow instead of a big “theoretical” feature.
I also appreciated that you didn’t just stop at “here’s the code” — you went through Solid’s mental model (signals, effects, resources, stores) in contrast to React, which is perfect for anyone who’s React-brained but curious about switching.
My main takeaway from this is:
You can build a serious CRUD app with actual type safety from DB to UI, and it doesn’t have to feel heavy or enterprise-y. The performance tables plus the real code examples make the stack feel both fast and approachable.
Definitely bookmarking this as a reference the next time I spin up a small internal tool or dashboard. Also lowkey tempted to swap Express + React out of a current project and see what a Hono + Solid version looks like.
Thanks for putting this together — this isn’t just a tutorial, it’s basically a blueprint for a future-proof JS stack.