# SolidStart

You are working in a SolidStart project with Solid 1.9, `@solidjs/router`, TypeScript, Tailwind CSS v4 and headless components from Ark UI or Kobalte. Write code for the versions in `package.json`, not the ones in your training data. Solid's JSX looks like React but works differently: a component runs once, and only the expressions that read signals update.

## Before you write code

- Read `package.json` for `solid-js`, `@solidjs/start`, `@solidjs/router`, `@ark-ui/solid` or `@kobalte/core`, and `tailwindcss`. These rules are for `solid-js` 1.x; if it says 2.x, its APIs differ, so read its docs first.
- SolidStart's config file moved between versions (`app.config.ts` while it ran on Vinxi, the Vite config later). Edit the one the project has; never add the other.
- Read `src/app.tsx` (the router and root layout), `src/entry-server.tsx` and `src/entry-client.tsx` before changing app-wide behavior. Check which router exports you have: `query` replaced `cache`, and route `preload` replaced `load`.
- SolidStart (`@solidjs/start`) is not TanStack Solid Start (`@tanstack/solid-start`). Don't mix their APIs.

## Reactivity: components run once

- Never destructure props: `function Card({ title })` reads `title` once and never updates. Read `props.title` where you use it, in JSX or inside a memo or effect.
- Split and default props without losing reactivity:

```tsx
import { mergeProps, splitProps, type ComponentProps } from "solid-js";
import { cn } from "~/lib/utils";

export function Card(props: ComponentProps<"div"> & { padded?: boolean }) {
  const merged = mergeProps({ padded: true }, props);
  const [local, rest] = splitProps(merged, ["class", "padded", "children"]);
  return (
    <div class={cn("rounded-xl border bg-card", local.padded && "p-6", local.class)} {...rest}>
      {local.children}
    </div>
  );
}
```

- No early returns that depend on state (`if (!props.user) return null`): the check runs once. Use `<Show>`.
- Signals are getters: `const [count, setCount] = createSignal(0)`, read as `count()`. Derive with a plain function (`const double = () => count() * 2`) or `createMemo` when the work is expensive or read in many places.
- `createEffect` is for side effects (DOM APIs, subscriptions, logging); call `onCleanup` inside it to dispose. Never set one signal from another in an effect: derive it. Effects don't run on the server; `onMount` runs once after the first render.
- Nested state is a store: `const [state, setState] = createStore({ todos: [] as Todo[] })`. Update through the setter with paths (`setState("todos", (t) => t.id === id, "done", true)`), `produce` for several changes, `reconcile` to merge fresh server data. Never assign to a store property directly.
- Ids that must match between server and client: `createUniqueId()`. Refs: `let input!: HTMLInputElement` with `ref={input}`, used in `onMount`.
- Read a `children` prop more than once only through the `children()` helper.

## JSX and control flow

- `<Show when={user()} fallback={<SignIn />}>{(u) => <p>{u().name}</p>}</Show>`: the callback gets a non-null accessor.
- Lists use `<For each={items()}>{(item) => …}</For>` (keyed by reference) or `<Index>` for primitives where position matters. Don't `.map()` arrays in JSX: every change rebuilds every row.
- Branches use `<Switch>` and `<Match>`. `<Dynamic component={…}>` and `<Portal>` come from `solid-js/web`.
- Write `class`, not `className`; toggle classes with `classList={{ active: active() }}` or `cn()`; `style={{ "font-size": "14px" }}` takes kebab-case keys.
- Text inputs listen with `onInput`: Solid's `onChange` is the DOM change event, which fires when the field loses focus.

## Routing and data

- File routes in `src/routes`: `index.tsx`, `about.tsx`, `posts/[id].tsx`, optional `[[lang]]`, catch-all `[...404].tsx`, groups `(marketing)/`. A file next to a folder of the same name (`posts.tsx` beside `posts/`) is its layout and renders `props.children`. Each route default-exports its component.
- The root in `src/app.tsx` wraps `<FileRoutes />` (from `@solidjs/start/router`) in `<Router root={…}>`, with `MetaProvider` from `@solidjs/meta` and a `<Suspense>` boundary around `props.children`.
- Reads are `query` functions consumed with `createAsync`, and preloaded by the route:

```tsx
import { createAsync, query, useParams, type RouteDefinition } from "@solidjs/router";
import { Show } from "solid-js";

const getPost = query(async (id: string) => {
  "use server";
  return db.post.findUnique({ where: { id } }); // your data layer
}, "post");

export const route = {
  preload: ({ params }) => getPost(params.id),
} satisfies RouteDefinition;

export default function PostPage() {
  const params = useParams();
  const post = createAsync(() => getPost(params.id));
  return <Show when={post()}>{(p) => <h1>{p().title}</h1>}</Show>;
}
```

- Read params and other reactive values inside the `createAsync` function, as above, so it refetches when they change. Add `{ deferStream: true }` to data that must be in the first HTML (titles, SEO); everything else streams.
- Wrap async regions in `<Suspense fallback={…}>` and risky ones in `<ErrorBoundary fallback={(err, reset) => …}>`. Render `<HttpStatusCode code={404} />` from `@solidjs/start` in not-found branches so the server sends a real 404.
- Set titles and tags in the route with `<Title>`, `<Meta name="description" content={…} />` and `<Link>` from `@solidjs/meta`.
- Link with `<A href="/posts">` from `@solidjs/router`; navigate in code with `useNavigate()`; read and write the query string with `useSearchParams()`.

## Server functions and actions

- `"use server"` as the first statement of a function (or a file) makes it a server function: an HTTP endpoint anyone can call. Validate every argument with a schema and check the session inside each one; get the request and `locals` with `getRequestEvent()` from `solid-js/web`.
- Writes are actions, used directly as a form's action so they work before JavaScript loads:

```tsx
import { action, useSubmission } from "@solidjs/router";
import { z } from "zod";

const NewTodo = z.object({ title: z.string().trim().min(1).max(200) });

const addTodo = action(async (form: FormData) => {
  "use server";
  const user = await requireUser(); // your session check
  const parsed = NewTodo.safeParse(Object.fromEntries(form));
  if (!parsed.success) return { errors: z.flattenError(parsed.error).fieldErrors };
  await createTodo(user.id, parsed.data);
}, "addTodo");

// <form action={addTodo} method="post"> … </form>
// const submission = useSubmission(addTodo); submission.pending, submission.result
```

- Return expected errors as values and read them from `useSubmission(action).result`; show `pending` on the submit button. Use `useAction(addTodo)` to call one from code, `useSubmissions` for optimistic lists.
- After an action, every active query revalidates. To refresh only some, return `json(data, { revalidate: getTodos.key })` or `reload({ revalidate: getTodos.key })`; send the user elsewhere with `throw redirect("/todos")`; refresh a query from client code with `revalidate(getTodos.key)`. All come from `@solidjs/router`.
- API routes (`src/routes/api/*.ts`) export `GET`, `POST` and the rest, take an `APIEvent` (type from `@solidjs/start/server`) and return a `Response`. Use them for webhooks and outside clients; pages read through queries, never by fetching their own API.
- Browser-only components load with `clientOnly(() => import("~/components/map"))` from `@solidjs/start`; branch on `isServer` from `solid-js/web` in shared code.
- Secrets are read with `process.env` inside server functions only. Values for the browser need the `VITE_` prefix and `import.meta.env`.

## Components: Ark UI or Kobalte

- With Ark UI, import parts per component (`import { Dialog } from "@ark-ui/solid/dialog"`) and compose `Dialog.Root`, `Dialog.Trigger`, `Dialog.Backdrop`, `Dialog.Positioner`, `Dialog.Content`, `Dialog.Title`, with the overlay parts inside `<Portal>`.
- Ark's `asChild` is a function: it receives a props getter to spread on your own element.

```tsx
<Dialog.Root open={open()} onOpenChange={(details) => setOpen(details.open)}>
  <Dialog.Trigger asChild={(triggerProps) => (
    <Button {...triggerProps()} variant="outline">Edit profile</Button>
  )} />
</Dialog.Root>
```

- Change handlers receive a details object (`details.open`, `details.value`), not the bare value.
- With Kobalte, follow its `as` prop for polymorphism and its own part names. Never mix the two libraries in one component.
- Style states with the data attributes the parts set (`data-[state=open]:`, `data-disabled:`), and keep variants in `tailwind-variants` or `cva` beside the component, merged with `cn()`.
- Every dialog has a title, every input a label; let the library manage focus and keyboard handling.

## Tailwind CSS v4

- Add `@tailwindcss/vite` to the Vite plugins in the project's config, and `@import "tailwindcss";` in `src/app.css`, imported once in `src/app.tsx`. No `tailwind.config.js`.
- Tokens are OKLCH variables in `:root` and `.dark`, mapped in `@theme inline { --color-background: var(--background); }`, with `@custom-variant dark (&:is(.dark *));`. Use `bg-background` and `text-muted-foreground`, never raw palette colors or `hsl(var(--x))`.

## Do not

- Do not destructure props, return early on state, or bring React habits: `useState`, `useEffect`, `className`, `onChange` on text inputs.
- Do not use SolidStart 0.x APIs: the `solid-start` package, `createRouteData`, `useRouteData`, `routeData`, `createServerData$`, `createServerAction$` or `server$`.
- Do not use `cache()` or a route `load` export when the router has `query` and `preload`, or fetch your own API routes from queries.
- Do not trust a server function's arguments, or put secrets behind a `VITE_` variable.

## Before you finish

- Run `tsc --noEmit` (or the project's typecheck script), the linter and a production build: server function bundling errors only show up there.
- Load each page with a hard refresh: data is in the server HTML and the console shows no hydration warnings.
- Submit every form with JavaScript disabled, then with it on, and check `pending` and returned errors.
- Call each server function and API route without a session and with bad input: expect a rejection, never private data.
