T
TanStack12mo ago
unwilling-turquoise

Client headers do not get passed to API routes called in loader/beforeLoad {TanStack Start}

I wanted to create an architecture where I have an API and frontend together using TanStack Start whilst having SSR so I tried to achieve this using the API routes provided by the framework. But when I want to fetch data from authenticated API routes in the loader the client's cookies/headers containing authentication info don't get passed to API. The only method I found to resolve this is to wrap the API call in a server function and use the getHeaders() function to pass the headers to the fetch call.
const fetchUser = createServerFn("GET", async () => {
const res = await fetch("http://localhost:3000/api/me", {
headers: getHeaders() as any,
});

if (!res.ok) {
return { data: null, error: "Server error" };
}

const data = await res.json();
return { data, error: null };
});
const fetchUser = createServerFn("GET", async () => {
const res = await fetch("http://localhost:3000/api/me", {
headers: getHeaders() as any,
});

if (!res.ok) {
return { data: null, error: "Server error" };
}

const data = await res.json();
return { data, error: null };
});
Is there any other way to resolve this without using a server function
23 Replies
yappiest-sapphire
yappiest-sapphire12mo ago
I believe you will need to manually get the client headers when in an SSR context. If you want an isomorphic approach you can make a wrapper around your http calls - for example I'm using ts-rest which has an easy way to customize each request, so this is what my client looks like
import { initQueryClient } from '@ts-rest/react-query';
import { contract } from '@repo/contract';
import { publicEnv } from '@repo/env';
import { tsRestFetchApi } from '@ts-rest/core';
import { isServer } from '@tanstack/react-query';

export const backend = initQueryClient(contract, {
baseUrl: publicEnv().backendUrl,
api: isServer
? async (args) => {
// Lazy import so not imported on the client
const { getHeaders, setHeaders } = await import('vinxi/http');
// Add request headers from client
const response = await tsRestFetchApi({
...args,
headers: {
...args.headers,
...getHeaders(),
},
});
// Write response headers to client
setHeaders(Object.fromEntries(response.headers));
return response;
}
: undefined,
});
import { initQueryClient } from '@ts-rest/react-query';
import { contract } from '@repo/contract';
import { publicEnv } from '@repo/env';
import { tsRestFetchApi } from '@ts-rest/core';
import { isServer } from '@tanstack/react-query';

export const backend = initQueryClient(contract, {
baseUrl: publicEnv().backendUrl,
api: isServer
? async (args) => {
// Lazy import so not imported on the client
const { getHeaders, setHeaders } = await import('vinxi/http');
// Add request headers from client
const response = await tsRestFetchApi({
...args,
headers: {
...args.headers,
...getHeaders(),
},
});
// Write response headers to client
setHeaders(Object.fromEntries(response.headers));
return response;
}
: undefined,
});
Then I can use my api client isomorphically from both a client and a ssr context Doing this with fetch is harder since there's no built in way to wrap each request, so you either need to build your own wrapper or use a 3rd party one that implements that functionality
unwilling-turquoise
unwilling-turquoiseOP12mo ago
Have you tried running a build. I always end up with build errors when I access getHeaders outside of a server function. This is my current workaround by wrapping the getHeader call in a server function
import type { ApiRoutes } from "@server/index";
import { isServer } from "@tanstack/react-query";
import { createServerFn } from "@tanstack/start";
import { env } from "env";
import { hc } from "hono/client";

const headerFn = createServerFn("GET", async () => {
const { getHeaders } = await import("vinxi/http");
return getHeaders() as Record<string, string>;
});

const client = hc<ApiRoutes>(env.VITE_APP_URL, {
headers: isServer ? async () => await headerFn() : undefined,
});

export const apiCLient = client.api;
import type { ApiRoutes } from "@server/index";
import { isServer } from "@tanstack/react-query";
import { createServerFn } from "@tanstack/start";
import { env } from "env";
import { hc } from "hono/client";

const headerFn = createServerFn("GET", async () => {
const { getHeaders } = await import("vinxi/http");
return getHeaders() as Record<string, string>;
});

const client = hc<ApiRoutes>(env.VITE_APP_URL, {
headers: isServer ? async () => await headerFn() : undefined,
});

export const apiCLient = client.api;
yappiest-sapphire
yappiest-sapphire12mo ago
Yeah build isn't working for me - I did something similar in solid start with vinxi and lazy imports worked if they were imported in a check for isServer but it seems like that doesn't work in tanstack router I bet using a server function there is a perfectly fine workaround
unwilling-turquoise
unwilling-turquoiseOP12mo ago
Thanks
yappiest-sapphire
yappiest-sapphire12mo ago
Take a look at vite-env-only for a server specific import, it's recommended in the react remix docs. I just set it up and it seems to be working well https://github.com/pcattori/vite-env-only
GitHub
GitHub - pcattori/vite-env-only
Contribute to pcattori/vite-env-only development by creating an account on GitHub.
yappiest-sapphire
yappiest-sapphire12mo ago
i added envOnlyMacros to my vite plugins, and then for my api client I have
import { contract } from '@repo/contract';
import { publicEnv } from '@repo/env';
import { tsRestFetchApi } from '@ts-rest/core';
import { initTsrReactQuery } from '@ts-rest/react-query/v5';
import { getHeaders, setHeaders } from 'vinxi/http';
import { serverOnly$ } from 'vite-env-only/macros';

export const tsr = initTsrReactQuery(contract, {
baseUrl: publicEnv().backendUrl,
credentials: 'include',
api: serverOnly$(async (args) => {
// Add request headers from client
const response = await tsRestFetchApi({
...args,
headers: {
...args.headers,
...getHeaders(),
},
});
// Write response headers to client
setHeaders(Object.fromEntries(Object.entries(response.headers)));
return response;
}),
});
import { contract } from '@repo/contract';
import { publicEnv } from '@repo/env';
import { tsRestFetchApi } from '@ts-rest/core';
import { initTsrReactQuery } from '@ts-rest/react-query/v5';
import { getHeaders, setHeaders } from 'vinxi/http';
import { serverOnly$ } from 'vite-env-only/macros';

export const tsr = initTsrReactQuery(contract, {
baseUrl: publicEnv().backendUrl,
credentials: 'include',
api: serverOnly$(async (args) => {
// Add request headers from client
const response = await tsRestFetchApi({
...args,
headers: {
...args.headers,
...getHeaders(),
},
});
// Write response headers to client
setHeaders(Object.fromEntries(Object.entries(response.headers)));
return response;
}),
});
serverOnly$ prevents the code inside and related imports from making it into the client bundle, and returns whatever is passed to it in a server context or undefined in a client context
unwilling-turquoise
unwilling-turquoiseOP12mo ago
Thanks. Yet to try it but posting the relevant remix documentation here for reference https://remix.run/docs/en/main/discussion/server-vs-client#vite-env-only
unwilling-turquoise
unwilling-turquoiseOP12mo ago
It worked successfully
import type { ApiRoutes } from "@server/index";
import { env } from "env";
import { hc } from "hono/client";
import { getHeaders } from "vinxi/http";
import { serverOnly$ } from "vite-env-only/macros";

const client = hc<ApiRoutes>(env.VITE_APP_URL, {
headers: serverOnly$(async () => {
return getHeaders() as Record<string, string>;
}),
});

export const apiCLient = client.api;
import type { ApiRoutes } from "@server/index";
import { env } from "env";
import { hc } from "hono/client";
import { getHeaders } from "vinxi/http";
import { serverOnly$ } from "vite-env-only/macros";

const client = hc<ApiRoutes>(env.VITE_APP_URL, {
headers: serverOnly$(async () => {
return getHeaders() as Record<string, string>;
}),
});

export const apiCLient = client.api;
My only issue is with the envOnlyMacros config plugin having a type error so I had to set it to any
import { defineConfig } from "@tanstack/start/config";
import tsConfigPaths from "vite-tsconfig-paths";
import { envOnlyMacros } from "vite-env-only";

export default defineConfig({
vite: {
plugins: () => [
envOnlyMacros() as any,
tsConfigPaths({
projects: ["./tsconfig.json"],
}),
],
},
});
import { defineConfig } from "@tanstack/start/config";
import tsConfigPaths from "vite-tsconfig-paths";
import { envOnlyMacros } from "vite-env-only";

export default defineConfig({
vite: {
plugins: () => [
envOnlyMacros() as any,
tsConfigPaths({
projects: ["./tsconfig.json"],
}),
],
},
});
harsh-harlequin
harsh-harlequin12mo ago
about that type error: most likely you have multiple versions of vite installed somehow make sure you use the same everywhere
yappiest-sapphire
yappiest-sapphire12mo ago
I also get the same type error - it seems there could be a discrepancy between the typings of the plugins field on vite.config.ts and the vite plugin’s plugin field on app.config.ts in vite.config.ts, plugins is PluginOption[] | undefined whereas the plugins field exported from the start config is ((...args: unknown[]) => Plugin<any>[]) | undefined. According to vite Plugin<any> is specifically a resolved plugin and PluginOption is a union of Plugin<any> plus a bunch of other types that vite can resolve to a plugin
harsh-harlequin
harsh-harlequin12mo ago
looking at this right now
yappiest-sapphire
yappiest-sapphire12mo ago
also, my output from bun pm ls --all | grep vite should mean I only have one version of vite installed? I've had some weirdness with packages installed with bun though so I'll try a clean install
├── @tanstack/start-vite-plugin@1.57.14
├── @vitejs/plugin-react@4.3.1
├── vite@5.4.6
├── vite-env-only@3.0.3
├── vite-tsconfig-paths@5.0.1
├── @tanstack/start-vite-plugin@1.57.14
├── @vitejs/plugin-react@4.3.1
├── vite@5.4.6
├── vite-env-only@3.0.3
├── vite-tsconfig-paths@5.0.1
harsh-harlequin
harsh-harlequin12mo ago
i don't know how bun works sorry but looks fine i just noticed with pnpm sometimes you can end up in situations where multiple versions are installed of deps and then types conflict
unwilling-turquoise
unwilling-turquoiseOP12mo ago
Just run a clean install and it didn't work Here's the type error
Type '() => (Plugin<any> | PluginOption[])[]' is not assignable to type '(...args: unknown[]) => Plugin<any>[]'.
Type '(Plugin<any> | PluginOption[])[]' is not assignable to type 'Plugin<any>[]'.
Type 'Plugin<any> | PluginOption[]' is not assignable to type 'Plugin<any>'.
Property 'name' is missing in type 'PluginOption[]' but required in type 'Plugin<any>'.
Type '() => (Plugin<any> | PluginOption[])[]' is not assignable to type '(...args: unknown[]) => Plugin<any>[]'.
Type '(Plugin<any> | PluginOption[])[]' is not assignable to type 'Plugin<any>[]'.
Type 'Plugin<any> | PluginOption[]' is not assignable to type 'Plugin<any>'.
Property 'name' is missing in type 'PluginOption[]' but required in type 'Plugin<any>'.
harsh-harlequin
harsh-harlequin12mo ago
harsh-harlequin
harsh-harlequin12mo ago
yappiest-sapphire
yappiest-sapphire12mo ago
types look good, let me make sure everything builds correctly
harsh-harlequin
harsh-harlequin12mo ago
love the fast feedback!
yappiest-sapphire
yappiest-sapphire12mo ago
haha of course thanks for the quick fix as well! build looking good
harsh-harlequin
harsh-harlequin12mo ago
so I also just tested this locally, works merging it
harsh-harlequin
harsh-harlequin12mo ago
GitHub
Release v1.58.8 · TanStack/router
Version 1.58.8 - 9/24/24, 5:41 PM Changes Fix start: use vite.PluginOption instead of vite.Plugin (#2407) (59064b6) by Manuel Schiller Packages @tanstack/start@1.58.8
unwilling-turquoise
unwilling-turquoiseOP12mo ago
Updated and it's working without any type errors now
harsh-harlequin
harsh-harlequin12mo ago
cool thanks for reporting

Did you find this page helpful?