Building a Full-Stack Web App with SolidStart: A Complete Hands-On Guide

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

What You'll Build

In this tutorial, you'll build a full-stack task manager application using SolidStart — the official meta-framework for SolidJS. By the end, you'll have a working app with:

  • File-based routing with nested layouts
  • Server functions using "use server" for secure backend logic
  • Reactive data loading with query and createAsync
  • Data mutations with action for form handling
  • SQLite storage using better-sqlite3
  • Full TypeScript support throughout
  • Server-side rendering (SSR) with streaming

Time required: 60–90 minutes


Prerequisites

Before starting, make sure you have:

  1. Node.js 20+ — run node --version to check
  2. Basic knowledge of HTML, CSS, and JavaScript
  3. Familiarity with reactive frameworks (React or Solid basics help)
  4. A code editor — VS Code with the Solid extension recommended

Why SolidStart?

What is SolidStart?

SolidStart is the official full-stack framework for SolidJS. It combines Solid's fine-grained reactivity with a powerful server runtime, giving you the best of both worlds.

FeatureDescription
Fine-grained reactivityUpdates only what changed — no virtual DOM diffing
File-based routingPages are defined by filesystem structure
Server functionsRun backend code with "use server" directive
Multiple rendering modesSSR, CSR, SSG, and streaming out of the box
Built on VinxiPowered by Nitro and Vite under the hood
Deploy anywherePresets for Vercel, Netlify, Cloudflare, AWS, and more

How does SolidStart compare?

If you've used Next.js, Nuxt, or SvelteKit, SolidStart fills the same role for SolidJS. The key difference is Solid's reactivity model — signals and effects instead of a virtual DOM — which delivers exceptional runtime performance.


Step 1: Create a New SolidStart Project

Open your terminal and run:

npx create-solid@latest task-manager

When prompted, select the following options:

  • Is this a SolidStart project? → Yes
  • Templatebasic
  • Use TypeScript? → Yes

Navigate into the project and install dependencies:

cd task-manager
npm install

Start the dev server to verify everything works:

npm run dev

Visit http://localhost:3000 — you should see the default SolidStart welcome page.


Step 2: Understand the Project Structure

Here's what SolidStart generated for you:

task-manager/
├── public/                 # Static assets
├── src/
│   ├── routes/            # File-based routing (pages live here)
│   │   └── index.tsx      # Home page (/)
│   ├── components/        # Reusable components
│   ├── app.tsx            # Root application shell
│   ├── entry-client.tsx   # Client-side hydration entry
│   └── entry-server.tsx   # Server-side rendering entry
├── app.config.ts          # SolidStart configuration
├── tsconfig.json
└── package.json

Key files explained

src/app.tsx — The root component that wraps your entire app. It sets up the router and defines the HTML shell:

import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Suspense } from "solid-js";
 
export default function App() {
  return (
    <Router
      root={(props) => (
        <Suspense>{props.children}</Suspense>
      )}
    >
      <FileRoutes />
    </Router>
  );
}

src/entry-server.tsx — Handles server-side rendering. You rarely need to modify this:

import { createHandler, StartServer } from "@solidjs/start/server";
 
export default createHandler(() => (
  <StartServer
    document={({ assets, children, scripts }) => (
      <html lang="en">
        <head>
          <meta charset="utf-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1" />
          <link rel="icon" href="/favicon.ico" />
          {assets}
        </head>
        <body>
          <div id="app">{children}</div>
          {scripts}
        </body>
      </html>
    )}
  />
));

Step 3: Set Up the Database

Install better-sqlite3 for a simple, file-based database:

npm install better-sqlite3
npm install -D @types/better-sqlite3

Create the database utility at src/lib/db.ts:

import Database from "better-sqlite3";
import { join } from "path";
 
const db = new Database(join(process.cwd(), "tasks.db"));
 
// Enable WAL mode for better performance
db.pragma("journal_mode = WAL");
 
// Create the tasks table if it doesn't exist
db.exec(`
  CREATE TABLE IF NOT EXISTS tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    description TEXT DEFAULT '',
    completed INTEGER DEFAULT 0,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
  )
`);
 
export { db };

This creates a SQLite database file (tasks.db) in your project root with a tasks table.


Step 4: Create Server Functions for CRUD Operations

Create src/lib/tasks.ts to define all server-side data operations:

import { action, query, redirect } from "@solidjs/router";
import { db } from "./db";
 
// Types
export interface Task {
  id: number;
  title: string;
  description: string;
  completed: number;
  created_at: string;
  updated_at: string;
}
 
// ─── Queries ───────────────────────────────────────────
 
export const getTasks = query(async (filter?: string) => {
  "use server";
  let sql = "SELECT * FROM tasks";
 
  if (filter === "active") {
    sql += " WHERE completed = 0";
  } else if (filter === "completed") {
    sql += " WHERE completed = 1";
  }
 
  sql += " ORDER BY created_at DESC";
  return db.prepare(sql).all() as Task[];
}, "tasks");
 
export const getTask = query(async (id: number) => {
  "use server";
  return db.prepare("SELECT * FROM tasks WHERE id = ?").get(id) as Task | undefined;
}, "task");
 
export const getTaskStats = query(async () => {
  "use server";
  const total = db.prepare("SELECT COUNT(*) as count FROM tasks").get() as { count: number };
  const completed = db.prepare("SELECT COUNT(*) as count FROM tasks WHERE completed = 1").get() as { count: number };
  return {
    total: total.count,
    completed: completed.count,
    active: total.count - completed.count,
  };
}, "taskStats");
 
// ─── Actions ───────────────────────────────────────────
 
export const addTask = action(async (formData: FormData) => {
  "use server";
  const title = formData.get("title")?.toString().trim();
  const description = formData.get("description")?.toString().trim() ?? "";
 
  if (!title) {
    throw new Error("Title is required");
  }
 
  db.prepare("INSERT INTO tasks (title, description) VALUES (?, ?)").run(title, description);
  throw redirect("/");
});
 
export const toggleTask = action(async (formData: FormData) => {
  "use server";
  const id = Number(formData.get("id"));
  db.prepare(
    "UPDATE tasks SET completed = CASE WHEN completed = 0 THEN 1 ELSE 0 END, updated_at = datetime('now') WHERE id = ?"
  ).run(id);
  throw redirect("/");
});
 
export const deleteTask = action(async (formData: FormData) => {
  "use server";
  const id = Number(formData.get("id"));
  db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
  throw redirect("/");
});
 
export const updateTask = action(async (formData: FormData) => {
  "use server";
  const id = Number(formData.get("id"));
  const title = formData.get("title")?.toString().trim();
  const description = formData.get("description")?.toString().trim() ?? "";
 
  if (!title) {
    throw new Error("Title is required");
  }
 
  db.prepare(
    "UPDATE tasks SET title = ?, description = ?, updated_at = datetime('now') WHERE id = ?"
  ).run(title, description, id);
  throw redirect(`/tasks/${id}`);
});

Key concepts explained

  • query() — Wraps a server function for data fetching. Results are cached and can be preloaded on navigation. The second argument ("tasks") is a cache key.
  • action() — Wraps a server function for mutations (create, update, delete). Actions automatically revalidate related queries.
  • "use server" — This directive tells SolidStart to run the function exclusively on the server. It never ships to the client bundle.
  • throw redirect("/") — After a mutation, redirect back to the home page. In SolidStart, redirects from actions use throw.

Step 5: Build the Root Layout with Navigation

Update src/app.tsx to include a navigation bar and global styles:

import { A, Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Suspense } from "solid-js";
import "./app.css";
 
export default function App() {
  return (
    <Router
      root={(props) => (
        <div class="app">
          <header class="header">
            <div class="container">
              <A href="/" class="logo">
                Task Manager
              </A>
              <nav>
                <A href="/" end>All Tasks</A>
                <A href="/tasks/new">New Task</A>
              </nav>
            </div>
          </header>
          <main class="container">
            <Suspense fallback={<div class="loading">Loading...</div>}>
              {props.children}
            </Suspense>
          </main>
        </div>
      )}
    >
      <FileRoutes />
    </Router>
  );
}

Create src/app.css with your base styles:

:root {
  --bg: #0f172a;
  --surface: #1e293b;
  --border: #334155;
  --text: #f1f5f9;
  --text-muted: #94a3b8;
  --primary: #3b82f6;
  --primary-hover: #2563eb;
  --success: #22c55e;
  --danger: #ef4444;
  --radius: 8px;
}
 
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
 
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: var(--bg);
  color: var(--text);
  line-height: 1.6;
}
 
.container {
  max-width: 800px;
  margin: 0 auto;
  padding: 0 1rem;
}
 
.header {
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  padding: 1rem 0;
  margin-bottom: 2rem;
}
 
.header .container {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
 
.logo {
  font-size: 1.25rem;
  font-weight: 700;
  color: var(--primary);
  text-decoration: none;
}
 
nav {
  display: flex;
  gap: 1rem;
}
 
nav a {
  color: var(--text-muted);
  text-decoration: none;
  padding: 0.5rem 1rem;
  border-radius: var(--radius);
  transition: all 0.2s;
}
 
nav a:hover,
nav a.active {
  color: var(--text);
  background: var(--border);
}
 
.loading {
  text-align: center;
  padding: 2rem;
  color: var(--text-muted);
}
 
input, textarea {
  width: 100%;
  padding: 0.75rem 1rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
  font-size: 1rem;
  outline: none;
  transition: border-color 0.2s;
}
 
input:focus, textarea:focus {
  border-color: var(--primary);
}
 
textarea {
  resize: vertical;
  min-height: 80px;
}
 
.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.625rem 1.25rem;
  border: none;
  border-radius: var(--radius);
  font-size: 0.875rem;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s;
  text-decoration: none;
}
 
.btn-primary {
  background: var(--primary);
  color: white;
}
 
.btn-primary:hover {
  background: var(--primary-hover);
}
 
.btn-danger {
  background: transparent;
  color: var(--danger);
  border: 1px solid var(--danger);
}
 
.btn-danger:hover {
  background: var(--danger);
  color: white;
}
 
.btn-sm {
  padding: 0.375rem 0.75rem;
  font-size: 0.8rem;
}

Step 6: Build the Home Page — Task List

Replace the content of src/routes/index.tsx with the task list page:

import { For, Show } from "solid-js";
import { A, useSearchParams } from "@solidjs/router";
import { createAsync, type RouteDefinition } from "@solidjs/router";
import { getTasks, getTaskStats, toggleTask, deleteTask } from "~/lib/tasks";
 
export const route = {
  preload: () => {
    getTasks();
    getTaskStats();
  },
} satisfies RouteDefinition;
 
export default function Home() {
  const [searchParams, setSearchParams] = useSearchParams();
  const filter = () => searchParams.filter as string | undefined;
 
  const tasks = createAsync(() => getTasks(filter()));
  const stats = createAsync(() => getTaskStats());
 
  return (
    <div>
      <div class="page-header">
        <h1>My Tasks</h1>
        <A href="/tasks/new" class="btn btn-primary">
          + Add Task
        </A>
      </div>
 
      <Show when={stats()}>
        {(s) => (
          <div class="stats">
            <div class="stat">
              <span class="stat-value">{s().total}</span>
              <span class="stat-label">Total</span>
            </div>
            <div class="stat">
              <span class="stat-value">{s().active}</span>
              <span class="stat-label">Active</span>
            </div>
            <div class="stat">
              <span class="stat-value">{s().completed}</span>
              <span class="stat-label">Done</span>
            </div>
          </div>
        )}
      </Show>
 
      <div class="filters">
        <button
          class={`filter-btn ${!filter() ? "active" : ""}`}
          onClick={() => setSearchParams({ filter: undefined })}
        >
          All
        </button>
        <button
          class={`filter-btn ${filter() === "active" ? "active" : ""}`}
          onClick={() => setSearchParams({ filter: "active" })}
        >
          Active
        </button>
        <button
          class={`filter-btn ${filter() === "completed" ? "active" : ""}`}
          onClick={() => setSearchParams({ filter: "completed" })}
        >
          Completed
        </button>
      </div>
 
      <Show
        when={tasks()?.length}
        fallback={
          <div class="empty-state">
            <p>No tasks yet. Create your first task!</p>
            <A href="/tasks/new" class="btn btn-primary">
              Create Task
            </A>
          </div>
        }
      >
        <ul class="task-list">
          <For each={tasks()}>
            {(task) => (
              <li class={`task-item ${task.completed ? "completed" : ""}`}>
                <form action={toggleTask} method="post" class="toggle-form">
                  <input type="hidden" name="id" value={task.id} />
                  <button type="submit" class="checkbox" aria-label="Toggle task">
                    {task.completed ? "✓" : ""}
                  </button>
                </form>
 
                <A href={`/tasks/${task.id}`} class="task-content">
                  <span class="task-title">{task.title}</span>
                  <Show when={task.description}>
                    <span class="task-desc">{task.description}</span>
                  </Show>
                </A>
 
                <form action={deleteTask} method="post">
                  <input type="hidden" name="id" value={task.id} />
                  <button type="submit" class="btn btn-danger btn-sm">
                    Delete
                  </button>
                </form>
              </li>
            )}
          </For>
        </ul>
      </Show>
 
      <style>{`
        .page-header {
          display: flex;
          justify-content: space-between;
          align-items: center;
          margin-bottom: 1.5rem;
        }
 
        .stats {
          display: flex;
          gap: 1rem;
          margin-bottom: 1.5rem;
        }
 
        .stat {
          flex: 1;
          background: var(--surface);
          border: 1px solid var(--border);
          border-radius: var(--radius);
          padding: 1rem;
          text-align: center;
        }
 
        .stat-value {
          display: block;
          font-size: 1.5rem;
          font-weight: 700;
          color: var(--primary);
        }
 
        .stat-label {
          font-size: 0.8rem;
          color: var(--text-muted);
          text-transform: uppercase;
          letter-spacing: 0.05em;
        }
 
        .filters {
          display: flex;
          gap: 0.5rem;
          margin-bottom: 1.5rem;
        }
 
        .filter-btn {
          padding: 0.5rem 1rem;
          background: var(--surface);
          border: 1px solid var(--border);
          border-radius: var(--radius);
          color: var(--text-muted);
          cursor: pointer;
          transition: all 0.2s;
        }
 
        .filter-btn:hover,
        .filter-btn.active {
          border-color: var(--primary);
          color: var(--text);
        }
 
        .task-list {
          list-style: none;
          display: flex;
          flex-direction: column;
          gap: 0.5rem;
        }
 
        .task-item {
          display: flex;
          align-items: center;
          gap: 0.75rem;
          padding: 1rem;
          background: var(--surface);
          border: 1px solid var(--border);
          border-radius: var(--radius);
          transition: border-color 0.2s;
        }
 
        .task-item:hover {
          border-color: var(--primary);
        }
 
        .task-item.completed .task-title {
          text-decoration: line-through;
          color: var(--text-muted);
        }
 
        .toggle-form {
          flex-shrink: 0;
        }
 
        .checkbox {
          width: 24px;
          height: 24px;
          border: 2px solid var(--border);
          border-radius: 4px;
          background: transparent;
          color: var(--success);
          font-size: 14px;
          cursor: pointer;
          display: flex;
          align-items: center;
          justify-content: center;
          transition: all 0.2s;
        }
 
        .checkbox:hover {
          border-color: var(--success);
        }
 
        .task-content {
          flex: 1;
          text-decoration: none;
          color: inherit;
        }
 
        .task-title {
          display: block;
          font-weight: 500;
        }
 
        .task-desc {
          display: block;
          font-size: 0.85rem;
          color: var(--text-muted);
          margin-top: 0.25rem;
        }
 
        .empty-state {
          text-align: center;
          padding: 3rem;
          color: var(--text-muted);
        }
 
        .empty-state p {
          margin-bottom: 1rem;
        }
      `}</style>
    </div>
  );
}

How data loading works

  1. route.preload — When a user navigates to this page (or hovers over a link), SolidStart calls preload to start fetching data early
  2. createAsync — Creates a reactive resource that suspends rendering until data is ready. Unlike React's useEffect, this integrates with <Suspense> automatically
  3. <For> — Solid's optimized loop component. It only re-renders individual items that change, not the entire list
  4. <Show> — Conditional rendering that avoids unnecessary DOM creation

Step 7: Create the New Task Page

Create src/routes/tasks/new.tsx:

import { A } from "@solidjs/router";
import { addTask } from "~/lib/tasks";
 
export default function NewTask() {
  return (
    <div>
      <A href="/" class="back-link">
        ← Back to tasks
      </A>
 
      <h1>Create New Task</h1>
 
      <form action={addTask} method="post" class="task-form">
        <div class="form-group">
          <label for="title">Title *</label>
          <input
            type="text"
            id="title"
            name="title"
            placeholder="What needs to be done?"
            required
            autofocus
          />
        </div>
 
        <div class="form-group">
          <label for="description">Description</label>
          <textarea
            id="description"
            name="description"
            placeholder="Add details (optional)"
            rows="4"
          />
        </div>
 
        <div class="form-actions">
          <A href="/" class="btn btn-danger">
            Cancel
          </A>
          <button type="submit" class="btn btn-primary">
            Create Task
          </button>
        </div>
      </form>
 
      <style>{`
        .back-link {
          display: inline-block;
          color: var(--text-muted);
          text-decoration: none;
          margin-bottom: 1.5rem;
          transition: color 0.2s;
        }
 
        .back-link:hover {
          color: var(--text);
        }
 
        h1 {
          margin-bottom: 1.5rem;
        }
 
        .task-form {
          background: var(--surface);
          border: 1px solid var(--border);
          border-radius: var(--radius);
          padding: 1.5rem;
        }
 
        .form-group {
          margin-bottom: 1.25rem;
        }
 
        .form-group label {
          display: block;
          margin-bottom: 0.5rem;
          font-weight: 500;
          font-size: 0.9rem;
        }
 
        .form-actions {
          display: flex;
          justify-content: flex-end;
          gap: 0.75rem;
          margin-top: 1.5rem;
        }
      `}</style>
    </div>
  );
}

How forms work in SolidStart

Notice there's no onSubmit handler or useState. The form uses a native HTML action pointing to the addTask server action. When submitted:

  1. SolidStart intercepts the form submission
  2. Serializes the FormData
  3. Sends it to the server function
  4. The server function processes the data and redirects
  5. All related query caches are automatically invalidated

This is progressive enhancement — the form works even without JavaScript enabled.


Step 8: Create the Task Detail Page

Create src/routes/tasks/[id].tsx — the brackets make id a dynamic route parameter:

import { Show } from "solid-js";
import { A, useParams } from "@solidjs/router";
import { createAsync, type RouteDefinition } from "@solidjs/router";
import { getTask, toggleTask, deleteTask, updateTask } from "~/lib/tasks";
import { createSignal } from "solid-js";
 
export const route = {
  preload: ({ params }) => getTask(Number(params.id)),
} satisfies RouteDefinition;
 
export default function TaskDetail() {
  const params = useParams();
  const task = createAsync(() => getTask(Number(params.id)));
  const [editing, setEditing] = createSignal(false);
 
  return (
    <div>
      <A href="/" class="back-link">
        ← Back to tasks
      </A>
 
      <Show when={task()} fallback={<p>Task not found.</p>}>
        {(t) => (
          <div class="task-detail">
            <Show
              when={!editing()}
              fallback={
                <form action={updateTask} method="post" class="edit-form">
                  <input type="hidden" name="id" value={t().id} />
                  <div class="form-group">
                    <label for="title">Title</label>
                    <input
                      type="text"
                      id="title"
                      name="title"
                      value={t().title}
                      required
                    />
                  </div>
                  <div class="form-group">
                    <label for="description">Description</label>
                    <textarea
                      id="description"
                      name="description"
                      rows="4"
                    >
                      {t().description}
                    </textarea>
                  </div>
                  <div class="form-actions">
                    <button
                      type="button"
                      class="btn btn-danger"
                      onClick={() => setEditing(false)}
                    >
                      Cancel
                    </button>
                    <button type="submit" class="btn btn-primary">
                      Save Changes
                    </button>
                  </div>
                </form>
              }
            >
              <div class="detail-header">
                <h1 class={t().completed ? "completed-title" : ""}>
                  {t().title}
                </h1>
                <div class="detail-actions">
                  <button
                    class="btn btn-primary btn-sm"
                    onClick={() => setEditing(true)}
                  >
                    Edit
                  </button>
                  <form action={toggleTask} method="post" style="display:inline">
                    <input type="hidden" name="id" value={t().id} />
                    <button type="submit" class="btn btn-sm" style={`background: ${t().completed ? "var(--border)" : "var(--success)"}; color: white;`}>
                      {t().completed ? "Mark Active" : "Mark Done"}
                    </button>
                  </form>
                  <form action={deleteTask} method="post" style="display:inline">
                    <input type="hidden" name="id" value={t().id} />
                    <button type="submit" class="btn btn-danger btn-sm">
                      Delete
                    </button>
                  </form>
                </div>
              </div>
 
              <Show when={t().description}>
                <div class="detail-description">
                  <h3>Description</h3>
                  <p>{t().description}</p>
                </div>
              </Show>
 
              <div class="detail-meta">
                <p>
                  <strong>Status:</strong>{" "}
                  <span class={`status ${t().completed ? "done" : "active"}`}>
                    {t().completed ? "Completed" : "Active"}
                  </span>
                </p>
                <p>
                  <strong>Created:</strong>{" "}
                  {new Date(t().created_at).toLocaleDateString()}
                </p>
                <p>
                  <strong>Last updated:</strong>{" "}
                  {new Date(t().updated_at).toLocaleDateString()}
                </p>
              </div>
            </Show>
          </div>
        )}
      </Show>
 
      <style>{`
        .back-link {
          display: inline-block;
          color: var(--text-muted);
          text-decoration: none;
          margin-bottom: 1.5rem;
          transition: color 0.2s;
        }
 
        .back-link:hover {
          color: var(--text);
        }
 
        .task-detail {
          background: var(--surface);
          border: 1px solid var(--border);
          border-radius: var(--radius);
          padding: 2rem;
        }
 
        .detail-header {
          display: flex;
          justify-content: space-between;
          align-items: flex-start;
          gap: 1rem;
          margin-bottom: 1.5rem;
        }
 
        .detail-header h1 {
          font-size: 1.5rem;
        }
 
        .completed-title {
          text-decoration: line-through;
          color: var(--text-muted);
        }
 
        .detail-actions {
          display: flex;
          gap: 0.5rem;
          flex-shrink: 0;
        }
 
        .detail-description {
          margin-bottom: 1.5rem;
          padding-bottom: 1.5rem;
          border-bottom: 1px solid var(--border);
        }
 
        .detail-description h3 {
          font-size: 0.9rem;
          color: var(--text-muted);
          text-transform: uppercase;
          letter-spacing: 0.05em;
          margin-bottom: 0.5rem;
        }
 
        .detail-meta p {
          margin-bottom: 0.5rem;
          font-size: 0.9rem;
          color: var(--text-muted);
        }
 
        .status {
          padding: 0.25rem 0.75rem;
          border-radius: 9999px;
          font-size: 0.8rem;
          font-weight: 500;
        }
 
        .status.active {
          background: rgba(59, 130, 246, 0.2);
          color: var(--primary);
        }
 
        .status.done {
          background: rgba(34, 197, 94, 0.2);
          color: var(--success);
        }
 
        .edit-form .form-group {
          margin-bottom: 1.25rem;
        }
 
        .edit-form label {
          display: block;
          margin-bottom: 0.5rem;
          font-weight: 500;
          font-size: 0.9rem;
        }
 
        .form-actions {
          display: flex;
          justify-content: flex-end;
          gap: 0.75rem;
          margin-top: 1rem;
        }
      `}</style>
    </div>
  );
}

Dynamic routes explained

  • [id].tsx — The brackets create a dynamic segment. /tasks/42 maps id to "42"
  • useParams() — Accesses the dynamic parameters reactively
  • createSignal — Solid's equivalent of React's useState, but with fine-grained reactivity. Only the DOM nodes that read the signal re-render

Step 9: Add an API Route

SolidStart supports API routes for building REST endpoints. Create src/routes/api/tasks.ts:

import type { APIEvent } from "@solidjs/start/server";
import { db } from "~/lib/db";
import type { Task } from "~/lib/tasks";
 
export async function GET(event: APIEvent) {
  const url = new URL(event.request.url);
  const filter = url.searchParams.get("filter");
 
  let sql = "SELECT * FROM tasks";
  if (filter === "active") sql += " WHERE completed = 0";
  else if (filter === "completed") sql += " WHERE completed = 1";
  sql += " ORDER BY created_at DESC";
 
  const tasks = db.prepare(sql).all() as Task[];
 
  return new Response(JSON.stringify(tasks), {
    headers: { "Content-Type": "application/json" },
  });
}
 
export async function POST(event: APIEvent) {
  const body = await event.request.json();
 
  if (!body.title?.trim()) {
    return new Response(JSON.stringify({ error: "Title is required" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }
 
  const result = db
    .prepare("INSERT INTO tasks (title, description) VALUES (?, ?)")
    .run(body.title.trim(), body.description?.trim() ?? "");
 
  const task = db.prepare("SELECT * FROM tasks WHERE id = ?").get(result.lastInsertRowid);
 
  return new Response(JSON.stringify(task), {
    status: 201,
    headers: { "Content-Type": "application/json" },
  });
}

Now you can test the API:

# Get all tasks
curl http://localhost:3000/api/tasks
 
# Create a task via API
curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn SolidStart", "description": "Build a task manager"}'

Step 10: Add Error Handling

Create an error boundary page at src/routes/*404.tsx:

import { A } from "@solidjs/router";
 
export default function NotFound() {
  return (
    <div class="not-found">
      <h1>404</h1>
      <p>The page you're looking for doesn't exist.</p>
      <A href="/" class="btn btn-primary">
        Go Home
      </A>
 
      <style>{`
        .not-found {
          text-align: center;
          padding: 4rem 0;
        }
 
        .not-found h1 {
          font-size: 4rem;
          color: var(--primary);
          margin-bottom: 0.5rem;
        }
 
        .not-found p {
          color: var(--text-muted);
          margin-bottom: 1.5rem;
        }
      `}</style>
    </div>
  );
}

Step 11: Configure and Build for Production

Update app.config.ts to configure your SolidStart app:

import { defineConfig } from "@solidjs/start/config";
 
export default defineConfig({
  server: {
    preset: "node-server",
  },
});

Build and run:

npm run build
node .output/server/index.mjs

Your production app is now running on port 3000.

Deploying to other platforms

SolidStart supports multiple deployment targets through presets:

PlatformPresetInstall
VercelvercelBuilt-in
NetlifynetlifyBuilt-in
Cloudflare Pagescloudflare-pagesBuilt-in
AWS Lambdaaws-lambdaBuilt-in
Node.jsnode-serverBuilt-in
Static (SSG)staticBuilt-in

Simply change the preset in your config:

export default defineConfig({
  server: {
    preset: "vercel", // Deploy to Vercel
  },
});

Testing Your Implementation

Let's verify everything works:

  1. Create tasks — Navigate to /tasks/new, fill in a title and description, submit
  2. View tasks — The home page shows all tasks with stats
  3. Filter tasks — Click "Active" or "Completed" to filter
  4. Toggle completion — Click the checkbox to mark tasks done/undone
  5. View details — Click a task title to see its detail page
  6. Edit tasks — On the detail page, click "Edit" to modify
  7. Delete tasks — Click "Delete" to remove a task
  8. Test the API — Use curl or your browser to hit /api/tasks

Troubleshooting

Common issues and fixes

"Cannot find module 'better-sqlite3'"

Make sure you installed it: npm install better-sqlite3 @types/better-sqlite3

"tasks.db is locked"

This can happen if multiple processes access the database. The WAL mode we enabled helps prevent this, but make sure only one dev server is running.

Styles not applying

Ensure you imported ./app.css in src/app.tsx. SolidStart uses Vite, so CSS imports work out of the box.

TypeScript errors with route params

Always convert route params to the expected type: Number(params.id) — params are always strings.


SolidStart vs Other Frameworks

FeatureSolidStartNext.jsSvelteKitNuxt
ReactivityFine-grained signalsVirtual DOMCompile-timeVirtual DOM (Vue)
Bundle size~7 KB~85 KB~15 KB~60 KB
Server functions"use server"Server ActionsForm actionsserver/ dir
Data loadingquery + createAsyncfetch in RSCload functionsuseFetch
Streaming SSRBuilt-inBuilt-inBuilt-inBuilt-in
File routingsrc/routes/app/src/routes/pages/

Next Steps

Now that you've built a full-stack app with SolidStart, here are some ideas to extend it:

  • Add authentication — Use Clerk or Lucia for user auth
  • Switch to PostgreSQL — Replace SQLite with a production database using Drizzle ORM
  • Add real-time updates — Use WebSockets or Server-Sent Events for live task updates
  • Implement drag-and-drop — Add task reordering with a Solid-compatible DnD library
  • Deploy to production — Try deploying to Vercel or Cloudflare Pages

Useful resources


Conclusion

You've built a complete full-stack task manager with SolidStart, covering:

  • File-based routing with dynamic parameters
  • Server functions using the "use server" directive for secure data access
  • Reactive data loading with query and createAsync
  • Form-based mutations with action and progressive enhancement
  • API routes for REST endpoints
  • SQLite storage for persistence
  • Production deployment with configurable presets

SolidStart brings the performance benefits of SolidJS's fine-grained reactivity to full-stack development, with an excellent developer experience powered by Vite and Vinxi. Its small bundle size, fast runtime, and intuitive APIs make it a compelling choice for modern web applications.


Want to read more tutorials? Check out our latest tutorial on Best Practices for Database Backup and Restoration.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles