Skip to content

Commit cfd1715

Browse files
Protected routes: write the guard as a server function
Document the intended behavior rather than the current router gap (solidjs/solid-router#603): requireUser is a "use server" function wrapped in query that throws redirect(), and the router navigates on it during server rendering and client-side navigations alike. Remove the caution that described the workaround. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ab8f57a commit cfd1715

2 files changed

Lines changed: 10 additions & 16 deletions

File tree

src/routes/(5)guides/(7)protected-routes.mdx

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,12 @@ Moving from `/account` to `/account/orders` keeps the pathless route mounted, th
117117
## Redirect before render
118118

119119
`requireUser` is the guard the preload starts and the component reads.
120-
It is a `query` around a plain async function that calls a server function and throws a redirect when there is no user:
120+
It is a server function wrapped in `query` that throws a redirect when the session has no user:
121121

122122
```ts
123123
// src/data/account.ts
124124
export const requireUser = query(async (next: string) => {
125+
"use server";
125126
const user = await getCurrentUser();
126127
if (!user) throw redirect(paths["sign-in"]({ next }));
127128
return user;
@@ -133,22 +134,15 @@ Click **Account** in the header signed out and the URL becomes the same sign-in
133134
Sign in, and the next visit to the account area runs the check again and finds the user, because a router action's completion invalidates every cached query.
134135

135136
The preload is what starts the check as soon as the route matches, and on hover, before `RequireUser` exists; the memo is what holds the navigation until the check answers.
136-
Both call the same function, and the function has no `"use server"` directive of its own.
137-
It runs where the read happens, on the server during server rendering and in the browser during a navigation, and calls `getCurrentUser` across the boundary.
138-
139-
:::caution[Throw the redirect in the query, not inside the server function]
140-
Moving the `throw redirect(...)` into a `"use server"` body and wrapping that function in `query` redirects on a full page request, because the function runs in-process during server rendering, but not on a client-side navigation.
141-
The server-function transport delivers a redirect to its caller as a response object with the target in an `X-Server-Function-Redirect` header and no `Location`; `query` navigates only on `Location`, so the read settles with that object and the navigation completes as if the check had passed.
142-
Router actions decode that header, which is why `throw redirect()` inside a server function works from a form or an action.
143-
For a read, build the redirect in the function `query` wraps, as above.
144-
:::
137+
Both call the same function.
138+
Its body runs on the server either way: in-process during server rendering, where it shares the request's `getCurrentUser` result with the header, and through the server-function endpoint during a navigation, where the thrown redirect travels back to the router as the response.
145139

146140
Hovering the **Account** link runs the preload with the `"preload"` intent, which starts the request and does not act on its outcome, so a hover never redirects; the click that follows does, whether it reuses that answer or fetches a new one.
147141

148142
:::deep-dive[What the router does with a thrown redirect]
149143
`query` treats a value thrown by the wrapped function the same way as a returned one.
150-
When the value is a `Response`, the router copies its headers onto the request event's response and reads its `Location` header.
151-
A relative `Location` becomes a call to the router's `navigate` with `replace: true`.
144+
When the value is a redirect `Response`, whether thrown in-process or delivered by the server-function transport, the router copies its headers onto the request event's response and reads the target.
145+
A same-origin target becomes a call to the router's `navigate` with `replace: true`.
152146
On the server, `navigate` records a 302 with that `Location` on the request event, and the read resolves `undefined` so the render can finish; a `Location` set before the shell flushes turns the whole response into a redirect with no body, and one set after the flush is appended to the stream as a script that sets `window.location`, as the [streaming renderer](/concepts/rendering-and-ssr#streaming-rendering) documents.
153147
In the browser the read stays pending forever, because the navigation unmounts everything that was waiting on it; the account page never receives a value, `undefined` included.
154148
Any `X-Revalidate` keys on the redirect are invalidated before the navigation so the destination fetches fresh.
@@ -332,7 +326,7 @@ Chained client-side redirects stop after 100 with `Too many redirects` from the
332326
### The account page paints, then the sign-in page replaces it
333327

334328
The guard navigates after the user data has arrived, from an effect or a callback, so the page commits and the navigation follows it.
335-
Throw the redirect from inside the `query` function instead; the read stays pending, the navigation is held, and the sign-in page replaces the target before anything paints.
329+
Throw the redirect from inside the server function the `query` wraps instead; the read stays pending, the navigation is held, and the sign-in page replaces the target before anything paints.
336330
On a full page request, add the middleware so the server answers with a redirect status instead of rendering the shell first.
337331

338332
### The server function returns orders for a signed-out caller
@@ -349,7 +343,7 @@ Accept only paths that start with a single `/` and reject `//` and `/\`; run tha
349343

350344
- Decide once, on `locals.userId`, and run the decision in every server function, in the route guard, and in middleware; each layer covers a way in that the others do not see.
351345
- Put the account pages under a pathless route whose `preload` starts the check and whose component renders `props.children` behind it, and keep `/sign-in` beside the group.
352-
- Make the guard a `query` around a plain async function that calls a server function and throws `redirect()` itself, so the router sees the `Location` on the server and in the browser.
346+
- Make the guard a server function wrapped in `query` that throws `redirect()` when the session has no user; the router navigates on it during server rendering and on client-side navigations alike.
353347
- Build the sign-in target with `paths["sign-in"]({ next })`; the search object encodes the path.
354348
- Validate `next` inside the server function: a single leading `/`, no `//`, no `/\`, and not the sign-in page itself.
355349
- Return a redirect from middleware for full page requests under `/account`, and keep the function checks, because middleware does not see `/_server` or client-side navigations.
@@ -360,4 +354,4 @@ Accept only paths that start with a single `/` and reject `//` and `/\`; run tha
360354
- [Sessions and auth](/building-apps/sessions-and-auth): the cookie, `getSession()`, and the middleware that puts `userId` on the event this guide reads.
361355
- [Middleware and API routes](/building-apps/middleware-and-api-routes): the chain the `requireAccountSession` middleware joins, and how to protect API routes the same way.
362356
- [Forms](/guides/forms): inline validation messages and pending state for the sign-in form.
363-
- [SSR-safe code](/guides/ssr-safe-code): the checklist for code such as `requireUser` that runs on both sides.
357+
- [SSR-safe code](/guides/ssr-safe-code): the checklist for code such as `RequireUser` that runs on both sides.

src/routes/(7)glossary.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ See [Derive a store with a projection](/concepts/stores#derive-a-store-with-a-pr
388388

389389
### redirect
390390

391-
Returns a `Response` with a `Location` header from a server function: a router action navigates without a page load, a form post without JavaScript follows a 303, and plain code receives the `Response` object.
391+
Returns a `Response` with a `Location` header from a server function: a router action or query navigates without a page load, a form post without JavaScript follows a 303, and plain code receives the `Response` object.
392392

393393
See [Redirect the caller](/building-apps/server-functions/mutations-and-responses#redirect-the-caller).
394394

0 commit comments

Comments
 (0)