Claude Code with SolidStart: Rules for the Server
Why SolidStart without CLAUDE.md ships React habits into a Solid app
SolidStart is the full-stack framework for SolidJS: it adds server-side rendering, file-based routing, server functions through a "use server" directive, and a data layer built on Solid's fine-grained reactivity. The appeal is that Solid's reactivity is genuinely different from React's, components run once and signals update only the DOM nodes that depend on them, which makes it fast and predictable. The risk is that the difference is subtle enough that an assistant trained mostly on React generates code that looks right, compiles, and quietly breaks reactivity or leaks server code to the client.
A Claude Code session that wires up SolidStart without project rules produces code shaped by React habits. It destructures props at the top of a component, which in Solid reads the signal once and freezes the value, so the UI stops updating. It writes a server function but forgets the "use server" directive, so code that reads a database credential gets bundled into the client. It calls a signal getter outside a tracking scope and wonders why nothing reacts. It reaches for a useEffect mental model and writes a createEffect that fires at the wrong time. Each of these is the difference between an app that updates correctly and keeps secrets server-side and one that does neither, and the destructuring bug in particular produces a UI that simply stops responding with no error at all.
This guide covers the CLAUDE.md configuration that locks Claude Code into SolidStart patterns that preserve reactivity and the server boundary: the "use server" rules that keep secrets off the client, the prop-access rules that keep signals reactive, the data-layer conventions for createAsync and server functions, the routing structure, and permission hooks that keep an automated session from breaking the build. For the client-side reactivity model in depth, Claude Code with SolidJS covers signals, stores, and effects. For a comparison framework's full-stack model, Claude Code with SvelteKit covers the equivalent server boundary.
The SolidStart CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a SolidStart project it needs to declare: the framework version, the server-function boundary, the reactivity rules, the data-layer conventions, and the hard rules that block the React habits Claude carries in.
# SolidStart rules
## Framework
- SolidStart v1, SolidJS v1, file-based routing under src/routes/
- Server functions via "use server", data via createAsync + cache/query
- This is NOT React: components run ONCE, reactivity is fine-grained
## Server boundary (MANDATORY)
- Code touching secrets, the DB, or env runs ONLY inside "use server"
- "use server" is the FIRST line of a server function, no exceptions
- NEVER import a server-only module into an isomorphic component
- A function that reads process.env MUST be a server function
## Reactivity (MANDATORY, the React-habit traps)
- NEVER destructure props: use props.value, NOT const { value } = props
- Access signals by calling the getter: count(), NOT count
- createEffect for side effects, NOT for deriving state (use createMemo)
- Conditional rendering uses <Show>, lists use <For>, NOT array.map for reactivity
## Data layer
- Server data loaded with createAsync wrapping a cached query function
- query() wraps a server function for caching + revalidation
- action() for mutations, revalidate the relevant query after
## Routing
- Routes are files under src/routes/, route params via useParams
- Data preloaded with a route preload function, NOT fetched in the component body
## Hard rules
- NEVER destructure component props (breaks reactivity)
- NEVER omit "use server" on a function that touches secrets or the DB
- NEVER import server-only code into a component that ships to the client
- NEVER read a signal value without calling its getter
- NEVER use createEffect to compute derived state
- ALWAYS wrap server data in createAsync for SSR + streaming
- ALWAYS revalidate a query after the action that changes its data
- ALWAYS use <Show>/<For> for reactive conditionals and lists
Three rules here prevent the majority of the breakage Claude generates without them.
The no-destructuring rule is the one that causes the most baffling bugs. In React, destructuring props is idiomatic and harmless because the component re-runs on every render. In Solid, a component runs exactly once, and props is a reactive proxy, so const { value } = props reads value at that single moment and never again, freezing the UI. Claude destructures props by reflex because that is what React code does. The rule forbids it and requires props.value access, which keeps the read reactive.
The server-boundary rule prevents secret leakage. SolidStart decides what is server-only by the "use server" directive, and a function without it that touches a database credential or an env var gets bundled into the client output where anyone can read it. Claude writes the function body correctly but omits the directive, because the directive is a SolidStart-specific convention it does not always apply. The rule makes "use server" mandatory on any function that touches secrets, the database, or env.
The signal-access rule prevents dead reactivity. A Solid signal is a getter function, so count() reads the current value and subscribes the surrounding scope, while count is the function itself and reacts to nothing. Claude, used to React state being a plain value, sometimes references the signal without calling it. The rule requires calling the getter so reactivity is wired.
Install and project structure
Create the project with the SolidStart starter, which scaffolds the routing and server setup.
# New project
npm init solid@latest
# Add to an existing Solid project
npm install @solidjs/start @solidjs/router
Keep server-only code clearly separated so Claude never imports it into a client component.
src/
routes/ , file-based routes
lib/
server/
db.ts , database access, server-only
users.ts , user queries, all "use server"
queries.ts , cache() wrappers over server functions
actions.ts , action() wrappers for mutations
components/ , isomorphic components (run on server + client)
The structure makes the boundary visible. Everything under lib/server/ is server-only and touches the database or secrets. The queries.ts and actions.ts files wrap those server functions in Solid's cache and action helpers for the data layer. The components/ directory holds isomorphic code that runs in both environments, so Claude knows not to import lib/server/db.ts there. This separation is what the server-boundary rule enforces structurally.
Server functions that stay on the server
The single most important boundary in a SolidStart app is "use server". A function carrying the directive runs only on the server, and its body, including any secrets, never ships to the client.
// src/lib/server/users.ts
"use server";
import { db } from "./db";
export async function getUserById(id: string) {
// This runs ONLY on the server. The DB credential never reaches the client.
const user = await db.users.findById(id);
return user;
}
The "use server" on the first line is what makes this safe. SolidStart compiles the function into a server endpoint and replaces the client-side call with an RPC, so the database access and the credential stay on the server. Without the directive, the same code would be bundled into the client and the credential exposed. The directive is the entire mechanism, and the reason the CLAUDE.md makes it mandatory: Claude writes the correct body but the directive is the part it forgets.
To make the server function usable in the reactive data layer, wrap it in cache, which adds deduplication and revalidation.
// src/lib/queries.ts
import { cache } from "@solidjs/router";
import { getUserById } from "./server/users";
export const getUser = cache(getUserById, "user");
The cache wrapper gives the server function a key, here "user", so SolidStart can deduplicate concurrent calls and revalidate it after a mutation. The wrapped function is what components and route preloads call, and it integrates with createAsync for SSR and streaming. This is the data-layer convention the CLAUDE.md encodes, the server function does the work and cache makes it reactive.
Loading data with createAsync
SolidStart loads server data into components through createAsync, which wraps a cached query and handles SSR, streaming, and Suspense. The pattern keeps the fetch reactive and the component declarative.
// src/routes/users/[id].tsx
import { createAsync, useParams } from "@solidjs/router";
import { Show } from "solid-js";
import { getUser } from "../../lib/queries";
export default function UserPage() {
const params = useParams();
const user = createAsync(() => getUser(params.id));
return (
<Show when={user()} fallback={<p>Loading...</p>}>
{(u) => <h1>{u().name}</h1>}
</Show>
);
}
Several Solid-specific patterns are at work. createAsync(() => getUser(params.id)) creates a reactive resource that re-runs when params.id changes, with SSR and streaming handled by the framework. The <Show> component renders the fallback until the data resolves and then the child, which is the reactive way to do conditional rendering, not a ternary that Solid would not track correctly. The params.id is accessed without destructuring, keeping it reactive per the rule. The {(u) => ...} callback form gives a narrowed, non-null accessor inside the Show. This is idiomatic SolidStart, and it is exactly what the reactivity rules in the CLAUDE.md steer Claude toward instead of a React-style useEffect fetch.
Mutations with actions
Mutations in SolidStart use action, which wraps a server function and integrates with forms and revalidation. After the mutation, the relevant query is revalidated so the UI reflects the change.
// src/lib/actions.ts
import { action, revalidate } from "@solidjs/router";
import { getUser } from "./queries";
const updateNameServer = async (id: string, name: string) => {
"use server";
await db.users.update(id, { name });
};
export const updateName = action(async (formData: FormData) => {
const id = String(formData.get("id"));
const name = String(formData.get("name"));
await updateNameServer(id, name);
revalidate(getUser.key);
});
The action wraps the mutation so it can be wired to a form and tracked for pending state. The inner updateNameServer carries "use server" so the database write stays on the server. After the write, revalidate(getUser.key) tells SolidStart to refetch the user query, so any createAsync reading it updates automatically. This closes the stale-data gap: the mutation and the revalidation are in one place, and the CLAUDE.md rule requiring revalidation after an action is what keeps Claude from omitting it.
Reactivity without the React reflexes
Solid's reactivity rewards a few habits that differ from React. The two that Claude most often gets wrong are deriving state and accessing signals. Derived values use createMemo, not createEffect.
import { createSignal, createMemo } from "solid-js";
function Cart() {
const [items, setItems] = createSignal<number[]>([]);
// Derived state: createMemo, NOT createEffect writing to another signal
const total = createMemo(() => items().reduce((a, b) => a + b, 0));
return <p>Total: {total()}</p>;
}
createMemo computes a derived value that updates only when its dependencies change, and reading it with total() subscribes the surrounding scope. A React habit would reach for an effect that writes the total into a second signal, which creates an extra update cycle and a stale-value window. The items() call inside the memo reads the signal reactively, so the memo recomputes whenever items change. This is the pattern the reactivity rules protect, and it is the difference between Solid running at its intended efficiency and running like React with extra steps. For the full reactivity model, Claude Code with SolidJS covers signals, stores, and effect timing.
Permission hooks for SolidStart operations
A SolidStart project's risky automated operations are around the build and dependencies, where a broken change can take down SSR. Permission hooks in .claude/settings.local.json keep the destructive ones out of an automated session.
{
"permissions": {
"allow": [
"Bash(npm run dev*)",
"Bash(npm run build*)",
"Bash(npx tsc --noEmit*)",
"Bash(npm run start*)"
],
"deny": [
"Bash(rm -rf .output*)",
"Bash(rm -rf node_modules*)",
"Bash(npm install * --force*)",
"Bash(*.env*)"
]
}
}
The allow list permits the operations a SolidStart session needs: the dev server, a build, a type-check, and the production start. The deny list blocks deleting the build output, wiping node_modules, forced installs that could pull a mismatched Solid version, and any shell command that touches a .env file where secrets live. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can build and test freely without breaking the SSR output or leaking environment secrets.
Common Claude Code mistakes with SolidStart
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Destructured props
Claude generates: function C({ value }: Props) then uses value.
Correct pattern: function C(props: Props) then props.value, which stays reactive.
2. Missing "use server" directive
Claude generates: a function that reads the database without the directive.
Correct pattern: "use server" on the first line so the code stays off the client.
3. Server module imported into a component
Claude generates: import { db } from "../lib/server/db" in a client component.
Correct pattern: import only the cache-wrapped query, never the raw server module.
4. Signal referenced without calling it
Claude generates: <p>{count}</p> instead of <p>{count()}</p>.
Correct pattern: call the getter count() so the read is reactive.
5. createEffect used to derive state
Claude generates: an effect that computes a value into another signal.
Correct pattern: createMemo for derived values, effects only for side effects.
6. array.map for reactive lists
Claude generates: {items().map(...)} which does not track per-item.
Correct pattern: <For each={items()}>{(item) => ...}</For> for reactive lists.
Add each pattern to CLAUDE.md with the corrected form. Combined with CLAUDE.md examples for the general structure, the result is SolidStart code Claude generates correctly the first time.
Testing the SolidStart integration
The tests that matter for SolidStart prove two things: a server function is not reachable from client code, and a component stays reactive after a signal changes. The reactivity test in particular catches the destructuring bug that produces no error.
import { describe, it, expect } from "vitest";
import { render } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import { Counter } from "../src/components/Counter";
describe("reactivity", () => {
it("updates the DOM when the signal changes", () => {
const { getByText, queryByText } = render(() => <Counter start={0} />);
getByText("0");
getByText("increment").click();
// If props were destructured, this would still read 0 and fail
expect(queryByText("1")).not.toBeNull();
});
});
The test renders a counter, clicks increment, and asserts the DOM shows the new value. If the component destructured its props or referenced a signal without calling it, the displayed value would stay frozen and the assertion would fail, which is exactly the silent reactivity bug surfaced as a test failure. Run it on every change to a component's props handling. For the broader testing setup, Claude Code with Vitest covers the configuration patterns.
Building SolidStart that keeps the server boundary and the reactivity
The SolidStart CLAUDE.md in this guide produces an app where every server function carries "use server" so secrets stay off the client, props are accessed without destructuring so the UI stays reactive, signals are read through their getters, derived state uses createMemo, and mutations revalidate the queries they affect. The result is a codebase that renders on the server safely and updates on the client correctly, rather than one that leaks credentials or freezes the moment a React habit slips in.
The principle is the same as any framework integration with Claude Code where the framework differs from the assistant's default. SolidStart without a CLAUDE.md produces React-shaped code that compiles and breaks the two things Solid does differently: reactivity and the server boundary. The CLAUDE.md is what teaches Claude the Solid way and keeps it there.
For the client-side reactivity model in depth, Claude Code with SolidJS covers signals, stores, and effects. For a comparison framework's full-stack model, Claude Code with SvelteKit covers the equivalent server boundary. For the TypeScript patterns underneath, Claude Code with TypeScript covers the type rules that keep the data layer sound.
Get Claudify. Ship SolidStart code that keeps secrets on the server and the UI reacting to every change.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify