T
TanStack10mo ago
ambitious-aqua

Redirect from Middleware

Hello, is it possible to redirect from a middleware function? It doesn't seem to be working correctly for me when calling a serverFn from the client when the middleware would throw the redirect. I am using useServerFn for the serverFn, but not sure if there's an equivalent hook that is needed for the middleware as well; currently just getting the following error in the console:
react-dom_client.js?v=a8826ad7:6490 Uncaught Error: Switched to client rendering because the server rendering errored:

{to: ..., isRedirect: true, statusCode: ...}
react-dom_client.js?v=a8826ad7:6490 Uncaught Error: Switched to client rendering because the server rendering errored:

{to: ..., isRedirect: true, statusCode: ...}
Below is the code from the middleware
export const authMiddleware = createMiddleware().server(async ({ next }) => {
const request = getWebRequest();
const authResp = await auth.api.getSession({ headers: request.headers });
const user = authResp?.user;

if (!user) {
throw redirect({ to: "/sign-in" });
}

return next({ context: { user } });
});
export const authMiddleware = createMiddleware().server(async ({ next }) => {
const request = getWebRequest();
const authResp = await auth.api.getSession({ headers: request.headers });
const user = authResp?.user;

if (!user) {
throw redirect({ to: "/sign-in" });
}

return next({ context: { user } });
});
37 Replies
afraid-scarlet
afraid-scarlet10mo ago
can you please create GitHub issue for this so we can track it?
optimistic-gold
optimistic-gold7mo ago
Hello, did you find some solution how to respond to failing auth middleware? @alrightsure. Return 401 or 403 response with error message?
afraid-scarlet
afraid-scarlet7mo ago
was there an issue created?
optimistic-gold
optimistic-gold7mo ago
I got answer how to return 401, thank you a lot for help https://discord.com/channels/719702312431386674/1238170697650405547/1342341835837800478 However, this question how to redirect is still relevant. I would guess it should be possible to handle on client side.
afraid-scarlet
afraid-scarlet7mo ago
please create a minimal complete example and create a GitHub issue (if not existing yet)
fair-rose
fair-rose7mo ago
Just wanted to share that this is something I've been trying to figure out as well.
I have authMiddleware that runs before every protected server function. It works fine and checks auth state, but it is unclear how best to handle the case where the user is unauthorized. The ideal (IMO) case would be an easy way to redirect to "/sign-in" directly from middleware, but this doesn't currently work (and maybe there's a good reason for that). I can throw json or throw Error but then the only thing I've been able to get working involves handling the error for every serverFunction (I guess maybe there's a helper function that could wrap them, but haven't gotten that far). The version that I got working used try/catch on the server function call, then added an optional error prop to the return value, then in the component I have to check that error and navigate to /sign-in. Not really practical since I'd need to do that for every server function. Definitely following and curious what others have found to work well.
afraid-scarlet
afraid-scarlet7mo ago
I read about not being able to throw a redirect from Middleware but I think there is no GitHub issue created for that, right? if not existing, can you please create an issue with a complete minimal example as a GitHub repo?
fair-rose
fair-rose7mo ago
I'm not sure if I saw an Issue. I'd be okay if that wasn't allowed directly though. And yeah, I can make an example. The one thing I see in the docs is client middleware. I was thinking that if there was a way for client middleware to run on the response from the server function that would give me a single place to redirect on the client if the response had been an error. I haven't explored that yet since I assumed it would only run on the way out. I will say that this isn't a huge issue since the routes are protected separately, and after a short cache they will end up redirecting to sign-in anyway. So it's sort of an edge case where the session was ended outside the current tab, then the user clicks something and gets a 401 from the function and hits the error boundary. I was able to address my issue by using React Query to fetch the session client-side once in a while, with a short-ish staleTime. So if the user is logged out and tabs back to a logged in tab that query will be stale and refetch, and they'll redirect to sign-in without being able to click anything. So the 401 issue pretty much goes away for me and this is better anyway since an old logged-in tab won't show data when you navigate to it. However, I'll still check to see if that GitHub Issue exists. 🎉 I was able to get it to work by having an outer client middleware running. If the response is an error we can handle writing a new window location right there if it's "Unauthorized". The most common use-case I've seen is people wanting to go to /sign-in when the middleware comes back with unauthorized (as with OP for this thread).
const authClientMiddleware = createMiddleware().client(async ({ next }) => {
try {
return await next();
} catch (error) {
if (error === "Unauthorized") {
window.location.href = "/sign-in";
}
throw error;
}
});

export const authMiddleware = createMiddleware()
.middleware([authClientMiddleware])
.server(async ({ next }) => {
const request = getWebRequest();
if (!request) {
throw new Error("No request found");
}

const ctx = { headers: request.headers, setCookie };
const session = await auth.api.getSession(ctx);

const userId = session?.user.id;
const email = session?.user.email;

if (!userId || !email) {
throw json("Unauthorized", { status: 401 });
}

return next({
context: {
userId,
email,
},
});
});
const authClientMiddleware = createMiddleware().client(async ({ next }) => {
try {
return await next();
} catch (error) {
if (error === "Unauthorized") {
window.location.href = "/sign-in";
}
throw error;
}
});

export const authMiddleware = createMiddleware()
.middleware([authClientMiddleware])
.server(async ({ next }) => {
const request = getWebRequest();
if (!request) {
throw new Error("No request found");
}

const ctx = { headers: request.headers, setCookie };
const session = await auth.api.getSession(ctx);

const userId = session?.user.id;
const email = session?.user.email;

if (!userId || !email) {
throw json("Unauthorized", { status: 401 });
}

return next({
context: {
userId,
email,
},
});
});
afraid-scarlet
afraid-scarlet7mo ago
I still think we should support throwing a redirect from any middleware
fair-rose
fair-rose7mo ago
I've been working on an example repo but I'm realizing now that I can make things work with what we already have, even without a separate client middleware. So let's say we have a server function with server middleware that throws a redirect. - If called from beforeLoad or loader the redirect works, just as if the redirect was thrown from the server function (makes sense, it's a single function call so it shouldn't matter where the redirect error was thrown in the call stack). - If called from a component, we can wrap the server function in useServerFn and then the behavior is the same as beforeLoad/loader. One issue I previously had was that I'm using React Query and my query options are defined outside a component so I wasn't able to use a wrapped version. However, I think I can modify the options to take an optional wrapped function and use that in things like useSuspenseQuery in a component, but then just use the function directly if I'm in something like ensureQueryData/fetchQuery in a loader. Haven't tested that yet. I think the biggest problem is a DX concern. For example, if I have a single global middleware for auth that includes a redirect, I now would need to make sure every server function in the app is always wrapped when called outside a beforeLoad/loader or it won't work as expected. That seems hard to enforce in larger projects/teams. This DX concern isn't just for middleware though. A similar DX issue exists with server functions too since the developer would have to "just know" that a particular function expects to be able to redirect. It's unclear exactly what "problem" to raise in a GitHub Issue.
From a DX perspective, it seems like it'd be nice if I could automatically wrap every server function in useServerFn when possible - although I don't know the pros/cons of that (e.g., are there cases where I would not want to wrap them?).
For middleware, it seems like it is sort of working as expected right now within the same confines as server functions themselves. It also seems like it might be uncommon to be calling server functions outside of beforeLoad/loader or a component. If that's accurate then we're mainly left with the DX concern, since from a technical perspective we can make the redirects work for those cases.
afraid-scarlet
afraid-scarlet7mo ago
are you using @tanstack/react-router-with-query ? this automatically handles redirects in functions called by query
fair-rose
fair-rose7mo ago
Yeah, I have it set up like in the TanStack Start example w/ query. I'll have to take a look. Those cases may already be working. Thanks! Okay, took a look. It does not seem to be working. I've confirmed my only query client is in the router, as shown in the Start example * Many of my server functions were actions (create, delete, etc.) within useMutation hooks. So technically using react-query, but I found those needed to be wrapped in useServerFn. Not sure if this is expected. * My page data also uses query. It's called in the loader with ensureQueryData and then in the component with useSuspenseQuery. I tested this by setting a very short staleTime, logging out in another tab, and then switching tabs to trigger the refetch on the other tab.
* Using the server function directly: I can see the network requests in the browser devtools, including retries, and the responses all include the redirect response I expect. However, the app does not redirect. * Refactor the query to optionally use a wrapped server function (unwapped in loader, wrapped in component). Now redirects work. This is basically what the refactor is:
const whateverQueryOptions = (
wrappedGetWhatever?: ReturnType<typeof useServerFn<typeof getWhatever>>
) =>
queryOptions({
queryKey: ["whatever"],
queryFn: () => (wrappedGetWhatever ? wrappedGetWhatever() : getWhatever()),
const whateverQueryOptions = (
wrappedGetWhatever?: ReturnType<typeof useServerFn<typeof getWhatever>>
) =>
queryOptions({
queryKey: ["whatever"],
queryFn: () => (wrappedGetWhatever ? wrappedGetWhatever() : getWhatever()),
Then in the component version:
const getWhateverWrapped = useServerFn(getWhatever);
const whateverQuery = useSuspenseQuery(
whateverQueryOptions(getWhateverWrapped)
const getWhateverWrapped = useServerFn(getWhatever);
const whateverQuery = useSuspenseQuery(
whateverQueryOptions(getWhateverWrapped)
and no change in the loader:
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(whateverQueryOptions());
},
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(whateverQueryOptions());
},
afraid-scarlet
afraid-scarlet7mo ago
cc @Ryan Gillie
fair-rose
fair-rose7mo ago
To summarize everything above... Given a server function using server middleware that throws a redirect: 1. Calling it from beforeLoad/loader will handle the redirect. 2. Calling it in a component will handle the redirect if it's wrapped with useServerFn. 3. Calling it using React Query on the client will handle the redirect if it's wrapped with useServerFn. 1 and 2 are pretty much exactly what the docs say. 3 is consistent with 2 (function on client needs to be wrapped), although it seems like maybe it should be handled without needing to wrap it based on the message above?
afraid-scarlet
afraid-scarlet7mo ago
i was under the impression that react-router-with-query would handle this https://github.com/TanStack/router/blob/main/packages/react-router-with-query/src/index.tsx#L109-L148 which version are you using?
fair-rose
fair-rose7mo ago
Oh, nice! I didn't assume it was a brand new feature... figured it was like "how it always works" with router/query. I now see in the blame that Ryan just added that last week. My TanStack stuff is all 1.105 right now, the ancient days <checks notes> 11 days ago, lol. I'll get that updated and it seems like it'll work without the hack.
afraid-scarlet
afraid-scarlet7mo ago
yes, start really has a fast pace right now hard to keep up 😄
fair-rose
fair-rose7mo ago
Okay, so I just ran the same test again but without the wrapped server function, it does eventually redirect, but not until the end of the retries that Query does.
I'm using defaults for retries, so when I re-tab to the page with the stale query I see one network request immediately and it includes the redirect but nothing happens. Then 3 retries occur (all with redirect info in the response) and then the page finally redirects on the last one (4th total). So instead of instant like I was seeing in my wrapped version it takes 7 seconds. I also tried with retry: false and it redirected immediately.
Maybe a bug in the new feature? For a "normal" error we'd want to retry before treating it as an error, but for a "redirect" error we'd want to redirect right away I think.
afraid-scarlet
afraid-scarlet7mo ago
sounds like it should not retry then want to contribute that fix?
fair-rose
fair-rose7mo ago
Yeah, I can take a look and see if I can figure out the guts. From a quick glance it looks like the new code is in onError and my assumption based on the results is that onError doesn't get called until after retries have finished. We'd need to run this check after each request instead of onError.
optimistic-gold
optimistic-gold7mo ago
What I miss
afraid-scarlet
afraid-scarlet7mo ago
retry behavior not redirecting directly when retries are enabled only after retry count is reached
optimistic-gold
optimistic-gold7mo ago
Oh, yeah Lemme look into that Gonna cc @TkDodo 🔮 on this 😅 Because retries are done on the query and not the cache, so idk its possible to attach it to the defaultOptions:{ queries } because a query can just say retry: 3 and that gets overwritten
const ogDefaultOptions = queryClient.getDefaultOptions();
queryClient.setDefaultOptions({
...ogDefaultOptions,
queries: {
...ogDefaultOptions.queries,
retry: (failureCount, error) => {
const ogDefaultQueryRetry = ogDefaultOptions.queries?.retry;

if (isRedirect(error) || ogDefaultQueryRetry === undefined) {
return false;
}

if (typeof ogDefaultQueryRetry === "function") {
return ogDefaultQueryRetry(failureCount, error);
}
if (typeof ogDefaultQueryRetry === "number") {
return failureCount < ogDefaultQueryRetry;
}
return ogDefaultQueryRetry;
},
},
});
const ogDefaultOptions = queryClient.getDefaultOptions();
queryClient.setDefaultOptions({
...ogDefaultOptions,
queries: {
...ogDefaultOptions.queries,
retry: (failureCount, error) => {
const ogDefaultQueryRetry = ogDefaultOptions.queries?.retry;

if (isRedirect(error) || ogDefaultQueryRetry === undefined) {
return false;
}

if (typeof ogDefaultQueryRetry === "function") {
return ogDefaultQueryRetry(failureCount, error);
}
if (typeof ogDefaultQueryRetry === "number") {
return failureCount < ogDefaultQueryRetry;
}
return ogDefaultQueryRetry;
},
},
});
something like this work would, but again doing
const query = queryOptions({
queryKey: [...],
queryFn: () => whatever(),
retry: 3
})
const query = queryOptions({
queryKey: [...],
queryFn: () => whatever(),
retry: 3
})
would break it
afraid-scarlet
afraid-scarlet7mo ago
seems like we are missing some hook in query here
optimistic-gold
optimistic-gold7mo ago
Yeahhhhhh not sure what to do My way will totally handle it in the default sense but not the overwritten
afraid-scarlet
afraid-scarlet7mo ago
lets wait for TkDodo to chime in
fair-rose
fair-rose7mo ago
A workaround for now in my app is to set this in defaultOptions within router.tsx. But yeah, would break if I override it anywhere as Ryan mentions above.
retry: (failureCount, error) => {
// Never retry redirects
if (isRedirect(error)) {
return false;
}
// Default retry behavior
return failureCount < 3;
},
retry: (failureCount, error) => {
// Never retry redirects
if (isRedirect(error)) {
return false;
}
// Default retry behavior
return failureCount < 3;
},
In the docs I see that useQuery returns a failureReason that'll include the contents of the error property while the retries are ongoing. I confirmed locally that the redirect info is available immediately as it continues to retry. That specific fact may not be helpful to fix our case, but it shows that the idea of exposing the individual errors as they are happening is already present in Query.
optimistic-gold
optimistic-gold7mo ago
I confirmed locally that the redirect info is available immediately as it continues to retry
Do you mean like const { error } = useQuery() and error is a redirect?
fair-rose
fair-rose7mo ago
yeah, but it's const { failureReason } = useQuery() during retries. I did a console log and it printed out the same redirect object that comes back in the response when you throw redirect.
optimistic-gold
optimistic-gold7mo ago
ahhhhhhhhhhhh okay gotcha yeah lets see what tkdodo says, I'm very invested in this Because I just noticed we also use retries in ours (still on react-router, soon™️ porting to tr and then ts) so this is something we'd have to deal with eventually jesus I can't type, just got back from hockey 😅
rare-sapphire
rare-sapphire7mo ago
What's the issue exactly? If you want to turn off retries when there's a redirect error, you have to implement the retry function. If someone sets retry:3, then they want 3 retries, period - even if there's a redirect error. I don't see a problem with that ?
afraid-scarlet
afraid-scarlet7mo ago
we wanted to only retry if a "real" error was thrown. the understanding is that a redirect is not an error
rare-sapphire
rare-sapphire7mo ago
yeah that's fine - you need to implement the retry function for that this should probably happen automatically if handleRedirects is turned on in react-router-with-query ?
afraid-scarlet
afraid-scarlet7mo ago
but it won't catch this if someone specified retry:3 in a single query invocation so ideally we could "preprocess" the "error"
rare-sapphire
rare-sapphire7mo ago
The usual way would be try/catch in the queryFn. There is no additional transformation layer
afraid-scarlet
afraid-scarlet7mo ago
i know. hence the ask for another "hook" that would allow to generalize this handling
fair-rose
fair-rose7mo ago

Did you find this page helpful?