Building a Full-Stack Type-Safe CRUD App with SolidJS, Bun, Hono, and PostgreSQL



This content originally appeared on DEV Community and was authored by Mayuresh

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 postgres library which is fast and modern
  • The connection configuration points to our local PostgreSQL instance
  • This sql object 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:

  1. Hono Instance: We create a Hono app instance
  2. CORS Middleware: Allows our frontend to make requests
  3. Type Safety: We define a Todo type that matches our database schema
  4. SQL Queries: Using tagged template literals for safe SQL queries
  5. REST Endpoints: Standard CRUD operations
  6. Export AppType: This is the magic for RPC – we export the type of our API
  7. 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:

  1. createSignal: Reactive state that updates UI when changed
  2. createResource: Async data fetching with built-in loading/error states
  3. For: Efficiently renders lists (like map in React but optimized)
  4. Show: Conditional rendering (like && or ternary in React)
  5. 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

  1. Create a Todo: Fill in the form and click “Add Todo”
  2. Mark as Complete: Click on the todo title
  3. Edit a Todo: Click “Edit”, modify the text, and click “Update”
  4. 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

  1. Bun Runtime: 2-3x faster than Node.js
  2. SolidJS: No virtual DOM overhead, fine-grained reactivity
  3. Hono: Extremely fast routing and middleware
  4. 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

Resources


This content originally appeared on DEV Community and was authored by Mayuresh