
Sveltekit Patterns
- 1 installs
- 6 repo stars
- Updated August 3, 2026
- spences10/devhub-crm
Provides SvelteKit remote-function patterns for query, form, and command types with valibot validation and user-scoped security.
About
Documents SvelteKit remote functions (query, form, command) with valibot validation, user_id scoping, and .refresh() patterns. A developer uses it for routing and server-side logic in a SvelteKit app.
- Distinguishes query (read), form (mutations plus redirect), and command types
- Uses valibot schemas and query.batch() for N+1 prevention
Sveltekit Patterns by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,912 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/spences10/devhub-crm --skill sveltekit-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 6 |
| Last updated | August 3, 2026 |
| Repository | spences10/devhub-crm ↗ |
What it does
Provides SvelteKit remote-function patterns for query, form, and command types with valibot validation and user-scoped security.
Files
SvelteKit Patterns
Quick Start
// Query: Read data
export const get_contacts = query(() =>
db.prepare('SELECT * FROM contacts').all(),
);
// Form: Validated mutation with redirect
export const create = form(
v.object({ name: v.string() }),
async ({ name }) => {
db.prepare('INSERT INTO contacts ...').run(id, name);
redirect(303, '/contacts');
},
);
// Command: Mutation with refresh
export const delete_contact = command(v.string(), async (id) => {
db.prepare('DELETE FROM contacts WHERE id = ?').run(id);
await get_contacts().refresh();
return { success: true };
});Core Principles
- Types:
query(read),form(mutations + redirects),command
(mutations only)
- Validation: Use valibot schemas for all inputs
- Security: Always include
user_idin WHERE clauses - Batching: Use
query.batch()for N+1 prevention - Refresh: Call
.refresh()in form/command handlers - Use `.current`: Store queries in variables, check
.current === undefined for initial load
- No manual keys: Never use
refresh_key++or{#key}blocks - Return values: Commands must return
{ success: true }
Reference Files
- remote-functions.md - Complete
remote functions API
- routing.md - File-based routing patterns
- database-patterns.md - Advanced
database queries
Sveltekit Patterns
SvelteKit patterns for devhub-crm including routing, server functions, form actions, and remote functions. Use when building pages, handling forms, or implementing server-side logic with SvelteKit.
Structure
SKILL.md- Main skill instructionsreferences/- Detailed documentation loaded as neededscripts/- Executable code for deterministic operationsassets/- Templates, images, or other resources
Usage
This skill is automatically discovered by Claude when relevant to the task.
Database Access Patterns in Remote Functions
How to safely query the database within SvelteKit remote functions.
Basic Query Pattern
import { query } from '$app/server';
import { db } from '$lib/server/db';
import { getRequestEvent } from '$app/server';
import { auth } from '$lib/server/auth';
export const get_contacts = query(async () => {
// Get authenticated user
const event = getRequestEvent();
const session = await auth.api.getSession({
headers: event.request.headers,
});
const user_id = session?.user?.id;
// User-scoped query
const stmt = db.prepare(`
SELECT * FROM contacts WHERE user_id = ?
`);
return stmt.all(user_id);
});Row-Level Security
Always include user_id in WHERE clauses:
// ✅ Correct - user-scoped
const stmt = db.prepare(`
SELECT * FROM contacts WHERE id = ? AND user_id = ?
`);
const contact = stmt.get(contact_id, user_id);
// ❌ Wrong - security vulnerability!
const stmt = db.prepare(`
SELECT * FROM contacts WHERE id = ?
`);
const contact = stmt.get(contact_id);Batched Queries (N+1 Prevention)
Use query.batch() for loading multiple items:
export const get_profiles = query.batch(
v.string(), // Validation for each ID
async (usernames: string[]) => {
// Fetch ALL usernames in ONE query
const stmt = db.prepare(`
SELECT * FROM user_profiles
WHERE username IN (${usernames.map(() => '?').join(',')})
`);
const profiles = stmt.all(...usernames);
// Return lookup function
return (username: string) => {
return profiles.find((p) => p.username === username) ?? null;
};
},
);
// Usage on client:
// const profile1 = get_profiles('john');
// const profile2 = get_profiles('jane');
// Both execute in ONE database query!Transactions
For multi-table mutations:
import { form } from '$app/server';
import { db } from '$lib/server/db';
export const create_contact_with_tags = form(
v.object({
name: v.string(),
tag_ids: v.array(v.string()),
}),
async ({ name, tag_ids }) => {
const user_id = await getUserId();
// Wrap in transaction
const insert_contact_with_tags = db.transaction(() => {
// Insert contact
const contact_id = nanoid();
db.prepare(
`
INSERT INTO contacts (id, user_id, name, created_at)
VALUES (?, ?, ?, ?)
`,
).run(contact_id, user_id, name, Date.now());
// Insert contact_tags
const tag_stmt = db.prepare(`
INSERT INTO contact_tags (id, contact_id, tag_id, created_at)
VALUES (?, ?, ?, ?)
`);
for (const tag_id of tag_ids) {
tag_stmt.run(nanoid(), contact_id, tag_id, Date.now());
}
return contact_id;
});
// Execute transaction
const contact_id = insert_contact_with_tags();
redirect(303, `/contacts/${contact_id}`);
},
);JOIN Patterns
export const get_contacts_with_stats = query(async () => {
const user_id = await getUserId();
const stmt = db.prepare(`
SELECT
c.*,
COUNT(i.id) as interaction_count,
MAX(i.created_at) as last_interaction
FROM contacts c
LEFT JOIN interactions i ON c.id = i.contact_id
WHERE c.user_id = ?
GROUP BY c.id
ORDER BY last_interaction DESC
`);
return stmt.all(user_id);
});Pagination
export const get_contacts_paginated = query(
v.object({
page: v.pipe(v.number(), v.minValue(1)),
per_page: v.pipe(v.number(), v.minValue(1), v.maxValue(100)),
}),
async ({ page, per_page }) => {
const user_id = await getUserId();
const offset = (page - 1) * per_page;
// Count total
const count_stmt = db.prepare(`
SELECT COUNT(*) as total FROM contacts WHERE user_id = ?
`);
const { total } = count_stmt.get(user_id);
// Fetch page
const stmt = db.prepare(`
SELECT * FROM contacts
WHERE user_id = ?
LIMIT ? OFFSET ?
`);
const contacts = stmt.all(user_id, per_page, offset);
return {
contacts,
pagination: {
total,
page,
per_page,
total_pages: Math.ceil(total / per_page),
},
};
},
);Helper Function
Create a reusable auth helper:
// src/lib/server/remote-helpers.ts
import { getRequestEvent } from '$app/server';
import { auth } from '$lib/server/auth';
import { error } from '@sveltejs/kit';
export async function getUserId(): Promise<string> {
const event = getRequestEvent();
const session = await auth.api.getSession({
headers: event.request.headers,
});
if (!session?.user?.id) {
throw error(401, 'Unauthorized');
}
return session.user.id;
}
// Usage in remote functions:
export const get_data = query(async () => {
const user_id = await getUserId();
// ...
});Best Practices
1. Always use prepared statements - Never string concatenation 2. Always scope by user_id - Prevent cross-user data access 3. Use transactions for multi-table ops - All-or-nothing consistency 4. Batch queries when possible - Use query.batch() to prevent N+1 5. Validate inputs - Use valibot schemas 6. Handle errors - Return error objects from forms/commands
See Also
database-patternsskill for general DB operationsauth.remote.tsfor auth function examples
Remote Functions Reference
Complete API documentation for SvelteKit remote functions used in devhub-crm.
Overview
Remote functions provide type-safe RPC between client and server with automatic type inference.
Function Types
query
Read-only operations that fetch data.
Basic usage:
export const get_items = query(async () => {
return db.prepare('SELECT * FROM items').all();
});With validation:
export const get_item = query(
v.pipe(v.string(), v.minLength(1)),
async (id: string) => {
return db.prepare('SELECT * FROM items WHERE id = ?').get(id);
},
);Batching (N+1 optimization):
export const get_items = query.batch(
v.string(), // Input validation schema
async (ids: string[]) => {
const items = db
.prepare(
`SELECT * FROM items WHERE id IN (${ids.map(() => '?').join(',')})`,
)
.all(...ids);
// Return lookup function
return (id: string) => items.find((item) => item.id === id);
},
);form
Mutations with validation and redirects. Used for traditional form submissions.
Structure:
export const action_name = form(
validationSchema,
async (validatedData) => {
// Mutation logic
redirect(303, '/success-path');
},
);Example:
export const create_contact = form(
v.object({
name: v.pipe(v.string(), v.minLength(1, 'Name required')),
email: v.pipe(v.string(), v.email('Invalid email')),
}),
async ({ name, email }) => {
const event = getRequestEvent();
const session = await auth.api.getSession({
headers: event.request.headers,
});
const user_id = session?.user?.id;
const id = nanoid();
db.prepare(
`
INSERT INTO contacts (id, user_id, name, email, created_at)
VALUES (?, ?, ?, ?, ?)
`,
).run(id, user_id, name, email, Date.now());
redirect(303, '/contacts');
},
);Error handling:
export const save_item = form(
v.object({ name: v.string() }),
async (data) => {
try {
// Mutation
} catch (error) {
return { error: error.message };
}
redirect(303, '/success');
},
);command
Mutations that return data instead of redirecting. Used for AJAX-style interactions.
Basic usage:
export const delete_item = command(
v.pipe(v.string(), v.minLength(1)),
async (id: string) => {
db.prepare('DELETE FROM items WHERE id = ?').run(id);
return { success: true };
},
);Complex return:
export const update_settings = command(
v.object({
theme: v.string(),
notifications: v.boolean(),
}),
async (settings) => {
// Save settings
return {
success: true,
message: 'Settings saved',
updated_at: Date.now(),
};
},
);Accessing Request Context
Use getRequestEvent() to access headers, cookies, URL params:
import { getRequestEvent } from '$app/server';
export const my_function = query(async () => {
const event = getRequestEvent();
// Access headers
const userAgent = event.request.headers.get('user-agent');
// Access URL params
const searchParam = event.url.searchParams.get('q');
// Access session
const session = await auth.api.getSession({
headers: event.request.headers,
});
return { userAgent, searchParam, user: session?.user };
});Client Usage
Remote functions are automatically available on the client:
<script lang="ts">
import {
get_items,
create_contact,
delete_item,
} from './functions.remote';
// Query: Returns promise
const items = get_items();
// Form: Use with <form> element
const { data, submitting, errors } = create_contact();
// Command: Call programmatically
async function handleDelete(id: string) {
const result = await delete_item(id);
if (result.success) {
// Handle success
}
}
</script>
<!-- Form usage -->
<form method="POST" use:data>
<input name="name" />
<input name="email" type="email" />
<button disabled={$submitting}>Create</button>
{#if $errors.name}<span>{$errors.name}</span>{/if}
</form>
<!-- Query usage -->
{#await items}
Loading...
{:then data}
{#each data as item}
<div>{item.name}</div>
{/each}
{/await}Refreshing Data After Mutations
Default Behaviors
- Forms: Automatically refresh ALL queries on the page (mirrors
non-JS behavior)
- Commands: Refresh NOTHING by default (must explicitly opt-in)
Single-Flight Mutation (Recommended for Performance)
Call .refresh() on queries from within the form/command handler to refresh specific data in the same request:
// contacts.remote.ts
export const get_contacts = query(async () => {
const user_id = await get_current_user_id();
return db.query('SELECT * FROM contacts WHERE user_id = ?', [
user_id,
]);
});
export const create_contact = form(
v.object({
name: v.string(),
email: v.string(),
}),
async (data) => {
const user_id = await get_current_user_id();
await db.insert('contacts', { ...data, user_id });
// ✅ Single-flight mutation: refresh in same request
await get_contacts().refresh();
return { success: true };
},
);Why this is better:
- Without: 2 round trips (mutation request + separate refresh
request)
- With: 1 round trip (mutation with embedded refresh data in
response)
Alternative: Redirect After Save
If you redirect, the new page fetches fresh data automatically:
export const create_contact = form(schema, async (data) => {
const id = await db.insert('contacts', data);
redirect(303, `/contacts/${id}`); // New page loads fresh data
});Trade-off: Causes full page navigation but guarantees fresh data.
Commands Must Return Values
IMPORTANT: Commands must return a value for await to properly complete. Always return { success: true } or an error object.
export const delete_contact = command(v.string(), async (id) => {
const user_id = await get_current_user_id();
await db.query(
'DELETE FROM contacts WHERE id = ? AND user_id = ?',
[id, user_id],
);
// ✅ Explicitly refresh queries (commands don't refresh by default)
await get_contacts().refresh();
return { success: true }; // ✅ Required for proper async completion
});Why return values matter: Without a return value, the command may not fully complete before the next operation, causing UI updates to fail.
Reactive UI Updates
IMPORTANT: When remote functions call .refresh(), components update automatically. You do NOT need manual refresh triggers.
Using .current for Non-Blocking Updates (Recommended)
The Problem with `{#await}`: When you use {#await query()} and call .refresh(), it re-renders the entire block, causing scroll jumps and visual disruption.
The Solution: Use the .current property to access data non-blockingly. This keeps previous data visible while new data loads.
❌ BAD - Blocking Pattern (Causes Page Jumps):
<script lang="ts">
import { get_interactions, update_interaction } from './interactions.remote';
let edit_id = $state<string | null>(null);
async function save_edit() {
await update_interaction({ id: edit_id, ... });
edit_id = null;
await get_interactions().refresh(); // ⚠️ Causes page jump!
}
</script>
<!-- ❌ Re-renders entire block on .refresh() -->
{#await get_interactions() then interactions}
{#each interactions as interaction}
{#if edit_id === interaction.id}
<!-- edit form -->
<button onclick={save_edit}>Save</button>
{:else}
<!-- view mode -->
{/if}
{/each}
{/await}Why this is bad:
- Page scrolls to top when
.refresh()is called - Entire list disappears then reappears (jarring UX)
- Edit state is lost during re-render
- Component structure is completely recreated
✅ GOOD - Non-Blocking Pattern with .current:
<script lang="ts">
import { get_interactions, update_interaction } from './interactions.remote';
// Store query in a variable
const interactions_query = get_interactions();
let edit_id = $state<string | null>(null);
async function save_edit() {
await update_interaction({ id: edit_id, ... });
edit_id = null;
await interactions_query.refresh(); // ✅ Updates in place!
}
</script>
<!-- ✅ Only show spinner on INITIAL load -->
{#if interactions_query.error}
<p>Error loading data</p>
{:else if interactions_query.loading && interactions_query.current === undefined}
<p>Loading...</p>
{:else}
{@const interactions = interactions_query.current ?? []}
<!-- Optional: Add subtle loading indicator during refresh -->
<div class:opacity-60={interactions_query.loading}>
{#each interactions as interaction}
{#if edit_id === interaction.id}
<!-- edit form -->
<button onclick={save_edit}>Save</button>
{:else}
<!-- view mode -->
{/if}
{/each}
</div>
{/if}Why this is better:
- `.current` retains previous data during refresh - Unlike
await, which re-renders everything, .current keeps showing the old data while loading new data
- Scroll position preserved - No component recreation means no
scroll jump
- Smooth updates - Data updates in place without visual disruption
- Better UX - Optional
opacity-60class shows loading state
without hiding content
- Initial load detection - Check
.current === undefinedto show
spinner only on first load
Key Properties of Query Objects:
const query = get_data();
query.loading; // boolean - true when fetching
query.error; // Error | null - error state
query.current; // T | undefined - latest data (persists during refresh!)The pattern:
1. Initial load (.current === undefined): Show loading spinner 2. During refresh (.current has data + .loading === true): Keep showing data with optional opacity 3. After refresh: New data in .current, .loading becomes false
This pattern is especially important for:
- Inline editing (prevents scroll jumps)
- Real-time updates
- Optimistic UI updates
- Anywhere you need smooth data transitions
❌ DON'T Do This (Manual Refresh Pattern):
<script lang="ts">
import { get_tags, create_tag } from './tags.remote';
let refresh_key = $state(0); // ❌ Don't use manual refresh keys
async function handle_create(name: string, color: string) {
await create_tag({ name, color });
refresh_key++; // ❌ Don't manually increment keys
}
</script>
{#key refresh_key}
<!-- ❌ Don't wrap queries in {#key} blocks -->
{#await get_tags() then tags}
<!-- content -->
{/await}
{/key}Why this is wrong:
- Forces complete re-render of the entire block
- Feels like a page reload
- Defeats reactive updates
- Creates visual flash/jarring UX
✅ DO This (Reactive Pattern):
<script lang="ts">
import { get_tags, create_tag } from './tags.remote';
// No manual refresh state needed!
async function handle_create(name: string, color: string) {
await create_tag({ name, color });
// That's it! Component updates automatically
}
</script>
<!-- Just await the query directly - updates reactively when .refresh() is called -->
{#await get_tags() then tags}
<!-- content -->
{/await}Why this works:
- Your remote function calls
.refresh()internally:
export const create_tag = guarded_command(schema, async (data) => {
await db.insert('tags', data);
await get_tags().refresh(); // ← This triggers UI update
return { success: true };
});- Component updates in place without re-rendering
- Smooth, reactive UI updates
- No visual flash or reload feeling
Component Callbacks Are Unnecessary
❌ DON'T pass on_change callbacks to child components:
<!-- ❌ Don't do this -->
<SocialLinksManager
on_add={add_social_link}
on_delete={delete_social_link}
on_change={() => contact_query?.refresh()} <!-- ❌ Unnecessary! -->
/>✅ DO just call the remote functions directly:
<!-- ✅ Just pass the remote functions -->
<SocialLinksManager
on_add={add_social_link}
on_delete={delete_social_link}
<!-- No on_change needed! -->
/>Why this works:
- Remote functions already call
.refresh()internally - Parent component updates reactively automatically
- Less boilerplate, cleaner code
Client-Side Cache & Deduplication
Remote functions use a hidden client-side cache:
Cache Key: remote_function_id + stringified_payload
Example:
<script>
// Called 3 times across different components
const user1 = await get_user('user-123'); // Fetches from server
const user2 = await get_user('user-123'); // Cache hit!
const user3 = await get_user('user-456'); // Different ID, fetches
</script>Benefits:
- No need to hoist data loading to parent components
- Use queries wherever you need them
- Automatic deduplication across components
- Reduces network requests
- Reactive updates when
.refresh()is called
Best Practices
✅ DO:
- Use `.current` pattern for inline editing - Prevents page jumps
and provides smooth updates
- Store queries in variables -
const query = get_data()instead
of calling inline
- Show spinner only on initial load - Check
.current === undefined to avoid hiding content during refresh
- Let queries update reactively - no manual refresh keys needed
- Use
query.batch()for actual batching (N+1 prevention) - Always validate with schemas (endpoints are public!)
- Use auth helpers for consistent authentication
- Verify ownership in all mutations
- Return values from commands (
{ success: true }) - Call
.refresh()inside form/command handlers for single-flight
mutations
❌ DON'T:
- Use `{#await}` for inline editing - Causes page jumps and
re-renders entire blocks
- Hide content during refresh - Show spinners only when
.current === undefined
- Use manual refresh keys (
refresh_key++) or{#key}blocks around
queries
- Pass
on_changecallbacks to trigger manual refreshes - Use
query.batch()if you're not actually batching - Forget that commands don't refresh by default
- Use
window.location.reload()(defeats reactivity) - Forget that remote functions are public endpoints
- Assume route-based protection works
Examples from devhub-crm
See actual implementations in:
src/routes/auth.remote.ts- Authentication flowssrc/routes/@[username]/profile.remote.ts- Profile queries with
batching
src/routes/(app)/interactions/+page.svelte-.currentproperty
usage for inline editing
- Database patterns skill for query construction
SvelteKit Routing Reference
File-based routing patterns used in devhub-crm.
Route File Types
+page.svelte
Client-side page component.
<script lang="ts">
import { get_contacts } from './contacts.remote';
const contacts = get_contacts();
</script>
{#await contacts}
Loading...
{:then data}
<ul>
{#each data as contact}
<li>{contact.name}</li>
{/each}
</ul>
{/await}+server.ts
API route handler (REST endpoints).
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url }) => {
const id = params.id;
const filter = url.searchParams.get('filter');
return json({ id, filter });
};
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
// Handle mutation
return json({ success: true });
};\*.remote.ts
Remote function definitions (RPC-style).
import { query, form, command } from '$app/server';
export const get_data = query(async () => {
// Server logic
});
export const save_data = form(schema, async (data) => {
// Mutation with redirect
});
export const update_data = command(schema, async (data) => {
// Mutation with return value
});Dynamic Routes
[param]
Single parameter.
src/routes/users/[id]/+page.svelte
→ /users/123[...slug]
Catch-all parameter.
src/routes/docs/[...slug]/+page.svelte
→ /docs/getting-started
→ /docs/api/endpoints[[optional]]
Optional parameter.
src/routes/posts/[[page]]/+page.svelte
→ /posts (page = undefined)
→ /posts/2 (page = "2")@[username]
Custom pattern (used for profiles).
src/routes/@[username]/+page.svelte
→ /@john
→ /@janeLayout Routes
+layout.svelte
Shared layout for all child routes.
<!-- src/routes/dashboard/+layout.svelte -->
<script lang="ts">
import { get_current_user } from '../auth.remote';
const user = get_current_user();
</script>
{#await user then userData}
{#if userData}
<nav>Dashboard Navigation</nav>
<slot />
<!-- Child routes render here -->
{:else}
<p>Not authenticated</p>
{/if}
{/await}Route Groups
(group)
Group routes without affecting URL structure.
src/routes/(app)/dashboard/+page.svelte → /dashboard
src/routes/(app)/settings/+page.svelte → /settings
src/routes/(app)/+layout.svelte → Shared layoutAPI Patterns
REST Endpoints
// src/routes/api/contacts/+server.ts
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
export const GET: RequestHandler = async ({ url }) => {
const search = url.searchParams.get('q');
const stmt = db.prepare(`
SELECT * FROM contacts WHERE name LIKE ?
`);
const contacts = stmt.all(`%${search}%`);
return json(contacts);
};Dynamic API Routes
// src/routes/api/contacts/[id]/+server.ts
export const GET: RequestHandler = async ({ params }) => {
const contact = db
.prepare('SELECT * FROM contacts WHERE id = ?')
.get(params.id);
if (!contact) {
throw error(404, 'Contact not found');
}
return json(contact);
};
export const DELETE: RequestHandler = async ({ params }) => {
db.prepare('DELETE FROM contacts WHERE id = ?').run(params.id);
return json({ success: true });
};Examples from devhub-crm
src/routes/
├── (app)/ # Authenticated app routes
│ ├── dashboard/
│ ├── contacts/
│ └── +layout.svelte # App shell with auth check
├── (public)/ # Public marketing routes
│ ├── about/
│ └── +layout.svelte # Public layout
├── @[username]/ # Public profile pages
│ ├── +page.svelte
│ └── profile.remote.ts # Profile data functions
├── api/ # REST API endpoints
│ ├── health/+server.ts
│ └── auth/[...all]/+server.ts
└── auth.remote.ts # Auth remote functions