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

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
queryandcreateAsync - Data mutations with
actionfor 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:
- Node.js 20+ — run
node --versionto check - Basic knowledge of HTML, CSS, and JavaScript
- Familiarity with reactive frameworks (React or Solid basics help)
- 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.
| Feature | Description |
|---|---|
| Fine-grained reactivity | Updates only what changed — no virtual DOM diffing |
| File-based routing | Pages are defined by filesystem structure |
| Server functions | Run backend code with "use server" directive |
| Multiple rendering modes | SSR, CSR, SSG, and streaming out of the box |
| Built on Vinxi | Powered by Nitro and Vite under the hood |
| Deploy anywhere | Presets 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-managerWhen prompted, select the following options:
- Is this a SolidStart project? → Yes
- Template →
basic - Use TypeScript? → Yes
Navigate into the project and install dependencies:
cd task-manager
npm installStart the dev server to verify everything works:
npm run devVisit 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-sqlite3Create 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 usethrow.
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
route.preload— When a user navigates to this page (or hovers over a link), SolidStart callspreloadto start fetching data earlycreateAsync— Creates a reactive resource that suspends rendering until data is ready. Unlike React'suseEffect, this integrates with<Suspense>automatically<For>— Solid's optimized loop component. It only re-renders individual items that change, not the entire list<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:
- SolidStart intercepts the form submission
- Serializes the
FormData - Sends it to the server function
- The server function processes the data and redirects
- All related
querycaches 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/42mapsidto"42"useParams()— Accesses the dynamic parameters reactivelycreateSignal— Solid's equivalent of React'suseState, 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.mjsYour production app is now running on port 3000.
Deploying to other platforms
SolidStart supports multiple deployment targets through presets:
| Platform | Preset | Install |
|---|---|---|
| Vercel | vercel | Built-in |
| Netlify | netlify | Built-in |
| Cloudflare Pages | cloudflare-pages | Built-in |
| AWS Lambda | aws-lambda | Built-in |
| Node.js | node-server | Built-in |
| Static (SSG) | static | Built-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:
- Create tasks — Navigate to
/tasks/new, fill in a title and description, submit - View tasks — The home page shows all tasks with stats
- Filter tasks — Click "Active" or "Completed" to filter
- Toggle completion — Click the checkbox to mark tasks done/undone
- View details — Click a task title to see its detail page
- Edit tasks — On the detail page, click "Edit" to modify
- Delete tasks — Click "Delete" to remove a task
- Test the API — Use
curlor 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
| Feature | SolidStart | Next.js | SvelteKit | Nuxt |
|---|---|---|---|---|
| Reactivity | Fine-grained signals | Virtual DOM | Compile-time | Virtual DOM (Vue) |
| Bundle size | ~7 KB | ~85 KB | ~15 KB | ~60 KB |
| Server functions | "use server" | Server Actions | Form actions | server/ dir |
| Data loading | query + createAsync | fetch in RSC | load functions | useFetch |
| Streaming SSR | Built-in | Built-in | Built-in | Built-in |
| File routing | src/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
queryandcreateAsync - Form-based mutations with
actionand 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.
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

Build a Full-Stack Web App with Deno 2 and Fresh Framework
Learn how to build a full-stack task manager app with Deno 2 and Fresh framework. This hands-on tutorial covers islands architecture, server-side rendering, API routes, and Deno KV for data persistence.

Building a Full-Stack Web App with SvelteKit 2: A Comprehensive Hands-On Guide
Learn how to build a complete notes management app with SvelteKit 2. This hands-on tutorial covers file-based routing, server-side rendering, Form Actions, API routes, and deployment to Vercel.

Building a Multi-Tenant App with Next.js
Learn how to build a full-stack multi-tenant application using Next.js, Vercel, and other modern technologies.