DEV Community

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

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

Verify installation:

bun --version
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Backend Setup with Hono

Step 3: Initialize Backend

cd server
bun init -y
Enter fullscreen mode Exit fullscreen mode

Step 4: Install Backend Dependencies

bun add hono
bun add postgres
bun add @hono/node-server
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 10: Install Frontend Dependencies

bun install
bun add @hono/client
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

2. Effects - React to Changes

import { createEffect } from 'solid-js';

createEffect(() => {
  // This runs whenever count() changes
  console.log('Count is now:', count());
});
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 15: Run the Frontend

bun run dev
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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));
  }
});
Enter fullscreen mode Exit fullscreen mode

Error Boundaries

import { ErrorBoundary } from 'solid-js';

<ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}>
  <App />
</ErrorBoundary>
Enter fullscreen mode Exit fullscreen mode

Production Deployment

Building for Production

Backend:

cd server
bun build index.ts --outfile=dist/server.js --target=bun
Enter fullscreen mode Exit fullscreen mode

Frontend:

cd client
bun run build
Enter fullscreen mode Exit fullscreen mode

Environment Variables

Create server/.env:

DATABASE_URL=postgresql://user:password@localhost:5432/todo_app
PORT=3000
Enter fullscreen mode Exit fullscreen mode

Update server/db.ts:

import postgres from 'postgres';

const sql = postgres(process.env.DATABASE_URL!);

export default sql;
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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

Top comments (1)

Collapse
 
peacebinflow profile image
PEACEBINFLOW

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.