SolidStart Best Practices
Comprehensive development guide for SolidStart and SolidJS applications with patterns organized by impact level. Designed to help developers build performant, maintainable applications with fine-grained reactivity.
When to use this skill
Use SolidStart Best Practices when:
- •Building new SolidStart applications or features
- •Implementing data fetching with
queryandcreateAsync - •Managing state with signals and stores
- •Creating reactive components with control flow
- •Integrating with CMS backends (Payload CMS)
- •Optimizing SSR and streaming performance
- •Implementing forms, search, or real-time features
Key areas covered:
- •Data Fetching (CRITICAL): Server functions, queries, caching, preloading
- •Reactivity & State (CRITICAL): Signals, stores, derived state, effects
- •Component Patterns (HIGH): Control flow, blocks, renderers
- •Routing (HIGH): File-based routing, navigation, preloading
- •Performance (MEDIUM-HIGH): SSR optimization, Suspense management
- •Hooks & Utilities (MEDIUM): Reusable patterns with cleanup
- •Forms & Validation (MEDIUM): Form state, validation, submission
- •Testing (LOW-MEDIUM): Unit tests, store testing, E2E
Quick reference
Critical patterns
- •Use
query+createAsync- Server-side data fetching with caching - •Route preloading - Fetch data before component renders
- •Control flow components -
<Show>,<For>,<Switch>for conditional rendering - •Store pattern - Separate read-only state from mutation actions
- •SSR guards -
typeof window === "undefined"for browser-only code
Common patterns
Data fetching with preload:
import { query, createAsync, type RouteDefinition } from '@solidjs/router';
const getArticle = query(async (slug: string) => {
"use server";
return await getArticleBySlugQuery(slug, TENANT_SLUG);
}, "article");
export const route = {
preload: ({ params }) => getArticle(params.slug),
} satisfies RouteDefinition;
export default function ArticlePage() {
const params = useParams();
const article = createAsync(() => getArticle(params.slug));
return (
<Show when={article()} fallback={<Loading />}>
<Article data={article()} />
</Show>
);
}
Store with actions:
import { createStore } from 'solid-js/store';
const [state, setState] = createStore<PlayerState>({
isPlaying: false,
currentStream: null,
});
export const audioPlayerStore = state; // Read-only export
export const audioPlayerActions = {
setIsPlaying: (value: boolean) => setState('isPlaying', value),
setCurrentStream: (stream: PlayerChannel | null) => setState('currentStream', stream),
};
Derived state with memos:
import { createMemo } from 'solid-js';
const currentTitle = createMemo(() => {
return audioPlayerStore.streamMetadata?.title || audioPlayerStore.currentStream?.displayName || '';
});
Using the guidelines
The complete guidelines are organized by category below. Each pattern includes:
- •Correct/incorrect code comparisons
- •Specific use cases and impact
- •Integration with CMS and SSR
- •Real-world examples from the codebase
Categories overview
1. Data Fetching (CRITICAL)
Server-side data fetching is the foundation of SolidStart applications.
query + createAsync (Recommended):
const getPosts = query(async () => {
"use server";
const posts = await fetch("https://my-api.com/posts");
return await posts.json();
}, "posts");
export const route = {
preload: () => getPosts(),
} satisfies RouteDefinition;
export default function Page() {
const posts = createAsync(() => getPosts());
return (
<ErrorBoundary fallback={<div>Something went wrong!</div>}>
<For each={posts()}>{(post) => <PostCard post={post} />}</For>
</ErrorBoundary>
);
}
Benefits: Automatic caching, request deduplication, SSR optimized.
Streaming SSR with deferStream:
const data = createAsync(
async () => await fetchHeavyData(),
{ deferStream: true }
);
Cache invalidation:
import { revalidate } from "@solidjs/router";
revalidate(getHeaderByTenantQuery.key); // Invalidate all
revalidate(getHeaderByTenantQuery.keyFor(tenantSlug)); // Specific key
CMS query depth guidelines:
| Content Type | Depth | Reason |
|---|---|---|
| Headers/Navigation | 3 | tenant → navigation → navItems → references |
| Articles | 4 | content blocks with nested relationships |
| Channel Pages | 5 | deeply nested channel + shows + blocks |
2. Reactivity & State (CRITICAL)
SolidJS uses fine-grained reactivity with signals and stores.
createSignal basics:
import { createSignal } from 'solid-js';
const [count, setCount] = createSignal(0);
// Reading value (call the accessor)
const currentCount = count();
// Updating with value
setCount(5);
// Updating with function
setCount((prev) => prev + 1);
createEffect for side effects:
import { createSignal, createEffect } from 'solid-js';
const [count, setCount] = createSignal(0);
createEffect(() => {
console.log("Count changed:", count());
});
createMemo for derived values:
import { createMemo, createSignal } from 'solid-js';
const [count, setCount] = createSignal(0);
const isEven = createMemo(() => count() % 2 === 0);
console.log(isEven()); // true
setCount(3);
console.log(isEven()); // false
Store pattern for complex state:
import { createStore } from 'solid-js/store';
const [state, setState] = createStore<PlayerState>({
isPlaying: false,
currentStream: null,
});
// Export read-only store
export const audioPlayerStore = state;
// Export actions (mutations)
export const audioPlayerActions = {
setIsPlaying: (value: boolean) => setState('isPlaying', value),
setCurrentStream: (stream: PlayerChannel | null) => setState('currentStream', stream),
};
3. Component Patterns (HIGH)
SolidJS uses control flow components instead of JSX conditionals.
Conditional rendering with <Show>:
import { Show } from 'solid-js';
<Show when={data()} fallback={<Loading />}>
<Content data={data()} />
</Show>
List rendering with <For>:
import { For } from 'solid-js';
<For each={items()}>{(item) => <Item {...item} />}</For>
Multiple conditions with <Switch>:
import { Switch, Match } from 'solid-js';
<Switch>
<Match when={type === 'a'}><ComponentA /></Match>
<Match when={type === 'b'}><ComponentB /></Match>
</Switch>
Block pattern for CMS content:
// components/blocks/ArticleBlock/ArticleBlock.tsx
interface ArticleBlockProps {
block: ArticleBlockType;
}
export function ArticleBlock({ block }: ArticleBlockProps) {
return <section class={styles.container}>{/* ... */}</section>;
}
// Register in BlockRenderer.tsx
switch (block.blockType) {
case 'articleBlock': return <ArticleBlock block={block} />;
}
Renderer pattern:
// ContentRenderer - orchestrates content arrays
<ContentRenderer items={page.content} />
// BlockRenderer - renders individual blocks by type
<BlockRenderer block={blockData} />
// RichTextRenderer - converts Lexical JSON to HTML
<RichTextRenderer content={richTextContent} />
4. Routing (HIGH)
SolidStart uses file-based routing.
File conventions:
| Pattern | Example | URL |
|---|---|---|
index.tsx | routes/index.tsx | / |
[param].tsx | routes/sender/[stationId].tsx | /sender/rock-antenne |
[...slug].tsx | routes/[...404].tsx | Any unmatched |
(group)/ | routes/(home)/index.tsx | / (group doesn't affect URL) |
Route definition with preload:
import { type RouteDefinition } from "@solidjs/router";
export const route = {
preload: ({ params }) => {
getArticleBySlugQuery(params.slug, TENANT_SLUG);
},
} satisfies RouteDefinition;
Navigation:
import { A, useNavigate } from '@solidjs/router';
// Declarative - use <A> not <a>
<A href="/sender/rock-antenne">Rock Antenne</A>
<A href="/" end>Home</A> // 'end' for exact match
// Programmatic
const navigate = useNavigate();
navigate('/sender/rock-antenne');
5. Performance (MEDIUM-HIGH)
Optimize SSR and client-side performance.
Avoiding Suspense triggers:
// createResource triggers parent Suspense boundary
const [data] = createResource(() => fetchData());
// Signals + createEffect for isolated loading state
const [searchResults, setSearchResults] = createSignal(null);
const [isLoading, setIsLoading] = createSignal(false);
createEffect(on(query, async (q) => {
setIsLoading(true);
const results = await searchCMSQuery(q);
setSearchResults(results);
setIsLoading(false);
}));
SSR-safe dynamic imports:
async function getClientModule() {
if (typeof window === "undefined") return null;
return await import("browser-only-module");
}
Search with debouncing & abort:
export function useSearch(options: { debounceMs?: number } = {}) {
const [query, setQuery] = createSignal("");
const [debouncedQuery, setDebouncedQuery] = createSignal("");
let debounceTimer: ReturnType<typeof setTimeout>;
let abortController: AbortController | null = null;
onCleanup(() => {
clearTimeout(debounceTimer);
abortController?.abort();
});
createEffect(on(debouncedQuery, async (q) => {
abortController?.abort();
abortController = new AbortController();
try {
const response = await searchCMSQuery(q, 10, abortController.signal);
if (!abortController.signal.aborted) {
setSearchResults(response);
}
} catch (error) {
if (error instanceof Error && error.name !== "AbortError") {
setError("Search unavailable");
}
}
}));
}
6. Hooks & Utilities (MEDIUM)
Reusable patterns with proper cleanup.
Basic hook with cleanup:
import { onCleanup } from 'solid-js';
export function usePolling(callback: () => void, interval: number) {
const id = setInterval(callback, interval);
onCleanup(() => clearInterval(id));
}
Click outside detection:
export function useClickOutside(props: {
ref: () => HTMLElement | undefined;
callback: (event: Event) => void;
enabled?: Accessor<boolean>;
}) {
createEffect(() => {
if (props.enabled && !props.enabled()) return;
const handleClick = (event: Event) => {
const el = props.ref();
if (el && !el.contains(event.target as Node)) {
props.callback(event);
}
};
document.addEventListener("click", handleClick);
onCleanup(() => document.removeEventListener("click", handleClick));
});
}
Theme with localStorage persistence:
export function useTheme() {
const [theme, setTheme] = createSignal<"light" | "dark">("light");
onMount(() => {
const saved = localStorage.getItem("theme");
if (saved === "light" || saved === "dark") {
setTheme(saved);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
setTheme("dark");
}
});
createEffect(() => {
localStorage.setItem("theme", theme());
document.documentElement.setAttribute("data-theme", theme());
});
return { theme, setTheme, toggleTheme: () => setTheme(t => t === "light" ? "dark" : "light") };
}
Media queries:
export function useMediaQuery(query: string) {
const [matches, setMatches] = createSignal(false);
createEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia(query);
setMatches(mq.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener("change", handler);
onCleanup(() => mq.removeEventListener("change", handler));
});
return matches;
}
export const useIsMobile = () => useMediaQuery("(max-width: 767px)");
export const useIsDesktop = () => useMediaQuery("(min-width: 1024px)");
Key rules:
- •Use
onCleanupfor subscriptions, intervals, event listeners - •Return accessor functions (not raw values) to preserve reactivity
- •Guard SSR with
typeof window === "undefined"checks - •Provide fallback values for loading/error states
7. Forms & Validation (MEDIUM)
Form patterns with reactive validation.
Basic form pattern:
import { createSignal } from 'solid-js';
function ContactForm() {
const [email, setEmail] = createSignal('');
const [error, setError] = createSignal('');
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!email().includes('@')) {
setError('Invalid email');
return;
}
// Submit logic
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
inputmode="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
<Show when={error()}><span class="error">{error()}</span></Show>
<button type="submit">Submit</button>
</form>
);
}
Multi-field form hook:
export function useFormState(fields: CMSFormField[]) {
const [formData, setFormData] = createSignal<FormData>({});
const [errors, setErrors] = createSignal<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = createSignal(false);
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
fields.forEach(field => {
if (field.required && !formData()[field.name]) {
newErrors[field.name] = 'Required';
}
if (field.blockType === 'email' && !isValidEmail(formData()[field.name])) {
newErrors[field.name] = 'Invalid email';
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const isFormValid = createMemo(() =>
fields.filter(f => f.required).every(f => formData()[f.name])
);
return { formData, setFormData, errors, isSubmitting, validateForm, isFormValid };
}
8. Middleware & Configuration (MEDIUM)
SolidStart middleware and app configuration.
Middleware configuration:
// src/middleware/index.ts
import { createMiddleware } from "@solidjs/start/middleware";
export default createMiddleware({
onRequest: (event) => {
console.log("Request received:", event.request.url);
event.locals.startTime = Date.now();
},
onBeforeResponse: (event) => {
const duration = Date.now() - event.locals.startTime;
console.log(`Request took ${duration}ms`);
},
});
App config:
// app.config.ts
import { defineConfig } from "@solidjs/start/config";
export default defineConfig({
middleware: "src/middleware/index.ts",
});
CSP with nonce:
import { createMiddleware } from "@solidjs/start/middleware";
import { randomBytes } from "crypto";
export default createMiddleware({
onRequest: (event) => {
const nonce = randomBytes(16).toString("base64");
event.locals.nonce = nonce;
const csp = `
default-src 'self';
script-src 'nonce-${nonce}' 'strict-dynamic' 'unsafe-eval';
object-src 'none';
base-uri 'none';
`.replace(/\s+/g, " ");
event.response.headers.set("Content-Security-Policy", csp);
},
});
9. Testing (LOW-MEDIUM)
Unit tests with Vitest and reactive scope isolation.
Unit tests with createRoot:
import { createRoot } from "solid-js";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("useSearch", () => {
beforeEach(() => {
vi.useFakeTimers();
mockSearchCMSQuery.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("should debounce search requests", async () => {
await createRoot(async (dispose) => {
const { setQuery } = useSearch({ debounceMs: 300 });
setQuery("test");
expect(mockSearchCMSQuery).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(300);
expect(mockSearchCMSQuery).toHaveBeenCalledWith("test", 10, expect.any(AbortSignal));
dispose();
});
});
});
Store testing:
function resetStore() {
setAudioPlayerStore({
isPlaying: false,
currentStream: null,
streamMetadata: null,
});
}
describe("Audio Player Store", () => {
beforeEach(() => {
vi.clearAllMocks();
resetStore();
});
it("should update metadata", () => {
audioPlayerActions.setStreamMetadata({ title: "Song" });
expect(audioPlayerStore.streamMetadata?.title).toBe("Song");
});
});
Anti-patterns to avoid
Using <a> instead of <A>
// WRONG - full page reload <a href="/about">About</a> // CORRECT - client-side navigation <A href="/about">About</A>
Missing SSR guards
// WRONG - breaks SSR
fetchOptions.credentials = 'include';
// CORRECT - only client-side
if (typeof window !== 'undefined') {
fetchOptions.credentials = 'include';
}
Meta tags without Show guard
// WRONG - causes duplicate tags
<Title>{page()?.meta?.title || "Fallback"}</Title>
// CORRECT - wait for data
<Show when={page()}>
<Title>{page().meta?.title}</Title>
</Show>
Missing limit in CMS queries
// WRONG - Payload defaults to limit=10 GET /api/channels?depth=2 // CORRECT - explicit limit GET /api/channels?depth=2&limit=100
Using createResource for search
// WRONG - triggers parent Suspense boundary
const [results] = createResource(query, searchAPI);
// CORRECT - isolated loading state with signals
const [results, setResults] = createSignal(null);
createEffect(on(query, async (q) => {
setResults(await searchAPI(q));
}));
Memory leaks in hooks
| Issue | Solution |
|---|---|
| Race condition in global refs | Use promise-based accessor |
| Memory leak in callbacks | Return removal function |
| Timeout timer leak | Cleanup on all exit paths |
| localStorage errors | Wrap in try-catch |
| Pending requests on unmount | Use AbortController with onCleanup |
Implementation approach
When building a SolidStart application:
- •Use server functions: Mark data fetching with
"use server"for automatic SSR - •Preload routes: Export
routeobject withpreloadfunction - •Prefer signals over resources: For UI that shouldn't trigger Suspense
- •Clean up effects: Always use
onCleanupfor subscriptions - •Guard browser APIs: Check
typeof windowbefore using browser-only features
Key metrics to track
- •Time to First Byte (TTFB): Server response time
- •First Contentful Paint (FCP): When content becomes visible
- •Largest Contentful Paint (LCP): When main content is visible
- •Cumulative Layout Shift (CLS): Visual stability
- •Hydration time: Time to make page interactive
Resources
Version history
v1.0.0 (January 2026)
- •Initial release
- •Patterns from Antenne Deutschland codebase
- •Integration with Payload CMS
- •SSR and streaming best practices