diff --git a/osmium/package.json b/osmium/package.json
index 6c1f6881a..d4000e5db 100644
--- a/osmium/package.json
+++ b/osmium/package.json
@@ -38,6 +38,7 @@
"@solid-primitives/event-listener": "^2.4.6",
"@solid-primitives/marker": "^0.2.2",
"@solid-primitives/platform": "^0.2.1",
+ "@solid-primitives/storage": "^4.4.0",
"@solidjs/router": "1.0.0",
"@tailwindcss/typography": "^0.5.19",
"solid-heroicons": "^3.2.4",
diff --git a/osmium/src/mdx-components.tsx b/osmium/src/mdx-components.tsx
index c669e0853..2ab7fc26c 100644
--- a/osmium/src/mdx-components.tsx
+++ b/osmium/src/mdx-components.tsx
@@ -1,13 +1,20 @@
import {
For,
+ type JSX,
Match,
type ParentProps,
Switch,
children,
createMemo,
+ createSignal,
splitProps,
} from "solid-js";
import { isServer } from "solid-js/web";
+import {
+ cookieStorage,
+ makePersisted,
+ messageSync,
+} from "@solid-primitives/storage";
import { clientOnly } from "@solidjs/start";
import { Callout } from "./ui/callout";
@@ -44,25 +51,55 @@ export const DirectiveContainer = (
>
{_children}
-
-
-
- {(title) => {title}}
-
-
-
- {(title, idx) => (
-
- {_children[idx()]}
-
- )}
-
-
+
);
};
+const TabGroup = (props: {
+ syncKey?: string;
+ tabNames: string[];
+ panels: JSX.Element[];
+}) => {
+ const tabs = (
+ value?: () => string | undefined,
+ onChange?: (value: string) => void
+ ) => (
+
+
+
+ {(title) => {title}}
+
+
+
+ {(title, idx) => (
+
+ {props.panels[idx()]}
+
+ )}
+
+
+ );
+
+ if (!props.syncKey) return tabs();
+
+ // Groups sharing a sync key select together across the page and tabs,
+ // and the choice persists between visits.
+ const [openTab, setOpenTab] = makePersisted(createSignal(props.tabNames[0]), {
+ name: `tab-group:${props.syncKey}`,
+ sync: messageSync(new BroadcastChannel("tab-group")),
+ storage: cookieStorage.withOptions({
+ expires: new Date(Date.now() + 3e10),
+ }),
+ });
+ return tabs(openTab, setOpenTab);
+};
+
export const strong = (props: ParentProps) => (
{props.children}
);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9044d99d8..df0b1eb4f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -143,6 +143,9 @@ importers:
"@solid-primitives/platform":
specifier: ^0.2.1
version: 0.2.1(solid-js@1.9.14)
+ "@solid-primitives/storage":
+ specifier: ^4.4.0
+ version: 4.4.0(solid-js@1.9.14)
"@solidjs/router":
specifier: ^1.0.0
version: 1.0.0(solid-js@1.9.14)
diff --git a/src/routes/(3)building-apps/(3)server-functions.mdx b/src/routes/(3)building-apps/(3)server-functions.mdx
index 800dc10a1..9699da623 100644
--- a/src/routes/(3)building-apps/(3)server-functions.mdx
+++ b/src/routes/(3)building-apps/(3)server-functions.mdx
@@ -87,6 +87,31 @@ Leave writes on the default `POST` transport:
The mutation examples assume authentication middleware has populated typed `userId` and `isAdmin` fields on `event.locals`.
See [Sessions and authentication](/building-apps/sessions-and-auth) for the request-scoped pattern.
+::::tab-group[validation-library]
+
+:::tab[Valibot]
+
+```ts
+import { getRequestEvent, redirect } from "@solidjs/web";
+import * as v from "valibot";
+
+const UserName = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(100));
+
+export async function updateCurrentUser(form: FormData) {
+ "use server";
+ const event = getRequestEvent();
+ const userId = event?.locals.userId;
+ if (!userId) throw redirect("/sign-in");
+
+ const name = v.parse(UserName, form.get("name"));
+ await database.users.update(userId, { name });
+}
+```
+
+:::
+
+:::tab[Zod]
+
```ts
import { getRequestEvent, redirect } from "@solidjs/web";
import { z } from "zod";
@@ -104,6 +129,10 @@ export async function updateCurrentUser(form: FormData) {
}
```
+:::
+
+::::
+
A server function is a transport primitive and does not require a router or data cache.
## The request event
@@ -149,6 +178,38 @@ The client reference resolves to the decoded value.
Use `respond` when a value also needs status, headers, or revalidation metadata:
+::::tab-group[validation-library]
+
+:::tab[Valibot]
+
+```ts
+import { getRequestEvent, respond } from "@solidjs/web";
+import * as v from "valibot";
+
+const CreateUser = v.object({
+ name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(100)),
+ email: v.pipe(v.string(), v.email()),
+});
+
+export async function createUser(input: unknown) {
+ "use server";
+ const event = getRequestEvent();
+ if (!event?.locals.isAdmin) {
+ return respond({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const user = await database.users.create(v.parse(CreateUser, input));
+ return respond(user, {
+ status: 201,
+ headers: { "x-created-user": user.id },
+ });
+}
+```
+
+:::
+
+:::tab[Zod]
+
```ts
import { getRequestEvent, respond } from "@solidjs/web";
import { z } from "zod";
@@ -173,6 +234,10 @@ export async function createUser(input: unknown) {
}
```
+:::
+
+::::
+
Scripted callers receive the carried value, while the transport forwards the response metadata.
`redirect()` and `reload()` return standard `Response` objects carrying `Location` or revalidation headers.
Returning or throwing those responses preserves their control-flow metadata for an integration to interpret.
@@ -187,6 +252,38 @@ Use `respond()` for an intentional structured failure, or `markSafeError()` only
Solid Router can add caching, submissions, revalidation, forms, and single-flight data to server functions.
These wrappers are optional:
+::::tab-group[validation-library]
+
+:::tab[Valibot]
+
+```ts
+import { action, query } from "@solidjs/router";
+import { getRequestEvent, redirect } from "@solidjs/web";
+import * as v from "valibot";
+
+const UserName = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(100));
+
+export const getUsers = query(async () => {
+ "use server";
+ return database.users.all();
+}, "users");
+
+export const renameCurrentUser = action(async (form: FormData) => {
+ "use server";
+ const event = getRequestEvent();
+ const userId = event?.locals.userId;
+ if (!userId) throw redirect("/sign-in");
+
+ await database.users.update(userId, {
+ name: v.parse(UserName, form.get("name")),
+ });
+});
+```
+
+:::
+
+:::tab[Zod]
+
```ts
import { action, query } from "@solidjs/router";
import { getRequestEvent, redirect } from "@solidjs/web";
@@ -211,6 +308,10 @@ export const renameCurrentUser = action(async (form: FormData) => {
});
```
+:::
+
+::::
+
Place the `"use server"` directive inside the callback passed to `query()` or `action()`.
`query()` caches reads by its name and arguments and declares a wrapped server function as an HTTP `GET`.
diff --git a/src/routes/(3)building-apps/(4)sessions-and-auth.mdx b/src/routes/(3)building-apps/(4)sessions-and-auth.mdx
index 19416d3c2..231e2129d 100644
--- a/src/routes/(3)building-apps/(4)sessions-and-auth.mdx
+++ b/src/routes/(3)building-apps/(4)sessions-and-auth.mdx
@@ -118,6 +118,30 @@ Authentication identifies the caller.
Authorization decides whether that caller may perform an operation.
Both decisions belong in server code.
+::::tab-group[validation-library]
+
+:::tab[Valibot]
+
+```ts
+import { redirect } from "@solidjs/web";
+import * as v from "valibot";
+
+const UserName = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(100));
+
+export async function renameCurrentUser(form: FormData) {
+ "use server";
+ const session = await getSession();
+ if (!session?.userId) throw redirect("/sign-in");
+
+ const name = v.parse(UserName, form.get("name"));
+ await database.users.rename(session.userId, name);
+}
+```
+
+:::
+
+:::tab[Zod]
+
```ts
import { redirect } from "@solidjs/web";
import { z } from "zod";
@@ -134,6 +158,10 @@ export async function renameCurrentUser(form: FormData) {
}
```
+:::
+
+::::
+
Hiding a control in the browser does not authorize the corresponding server function or API route.
Check the session or other credentials again at every protected server entry point, then authorize access to the specific record.
Do not accept a user ID from the browser when the operation should target the signed-in user.
diff --git a/src/routes/(3)building-apps/(5)environment.mdx b/src/routes/(3)building-apps/(5)environment.mdx
index 5fd8287e1..ac68620aa 100644
--- a/src/routes/(3)building-apps/(5)environment.mdx
+++ b/src/routes/(3)building-apps/(5)environment.mdx
@@ -12,7 +12,39 @@ This environment layer belongs to start mode and is not enabled by `ssr: true` a
Add `env.ts` or `env.js` at the Vite project root.
Default-export optional `server` and `client` maps whose values implement Standard Schema.
-The official `solid-v2/fullstack` template uses Zod:
+The official `solid-v2/fullstack` template uses Valibot, but any Standard Schema library works:
+
+::::tab-group[validation-library]
+
+:::tab[Valibot]
+
+```ts
+import * as v from "valibot";
+
+const signingKeys = v.pipe(
+ v.unknown(),
+ v.transform((value) =>
+ typeof value === "string"
+ ? value.split(",").map((key) => key.trim())
+ : value
+ ),
+ v.array(v.pipe(v.string(), v.minLength(32))),
+ v.minLength(1)
+);
+
+export default {
+ server: {
+ SESSION_SECRET: signingKeys,
+ },
+ client: {
+ VITE_APP_NAME: v.optional(v.pipe(v.string(), v.minLength(1)), "Solid App"),
+ },
+};
+```
+
+:::
+
+:::tab[Zod]
```ts
import { z } from "zod";
@@ -35,6 +67,10 @@ export default {
};
```
+:::
+
+::::
+
The `SESSION_SECRET` input is a comma-separated list.
The schema validates every signing key after splitting the input, so a short or empty rotation key fails validation.
diff --git a/src/routes/(6)migration/(1)from-solid-start.mdx b/src/routes/(6)migration/(1)from-solid-start.mdx
index 1ddca1a30..c21ec3043 100644
--- a/src/routes/(6)migration/(1)from-solid-start.mdx
+++ b/src/routes/(6)migration/(1)from-solid-start.mdx
@@ -339,6 +339,38 @@ Keep secrets in server-only modules.
Start mode also supports a typed Standard Schema file at `env.ts` or `env.js` in the project root:
+::::tab-group[validation-library]
+
+:::tab[Valibot]
+
+```ts title="env.ts"
+import * as v from "valibot";
+
+const signingKeys = v.pipe(
+ v.unknown(),
+ v.transform((value) =>
+ typeof value === "string"
+ ? value.split(",").map((key) => key.trim())
+ : value
+ ),
+ v.array(v.pipe(v.string(), v.minLength(32))),
+ v.minLength(1)
+);
+
+export default {
+ server: {
+ SESSION_SECRET: signingKeys,
+ },
+ client: {
+ VITE_APP_NAME: v.pipe(v.string(), v.minLength(1)),
+ },
+};
+```
+
+:::
+
+:::tab[Zod]
+
```ts title="env.ts"
import { z } from "zod";
@@ -360,6 +392,10 @@ export default {
};
```
+:::
+
+::::
+
Import validated values from `virtual:env/server` in server-only modules and from `virtual:env/client` in shared or client code.
The plugin generates `solid-env.d.ts` next to the schema.
Do not copy a SolidStart `@solidjs/start/env` type reference.