Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/start/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@
"label": "Environment Functions",
"to": "framework/react/guide/environment-functions"
},
{
"label": "Cookies",
"to": "framework/react/guide/cookies"
},
{
"label": "Middleware",
"to": "framework/react/guide/middleware"
Expand Down Expand Up @@ -238,6 +242,10 @@
"label": "Environment Functions",
"to": "framework/solid/guide/environment-functions"
},
{
"label": "Cookies",
"to": "framework/solid/guide/cookies"
},
{
"label": "Middleware",
"to": "framework/solid/guide/middleware"
Expand Down
65 changes: 65 additions & 0 deletions docs/start/framework/react/guide/cookies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
id: cookies
title: Cookies
---

Start ships two ways to read and write cookies:

- **Server-only**: `getCookie`/`setCookie`/`getCookies`/`deleteCookie` from `@tanstack/react-start/server`. These only work inside an active server request (a server function, request middleware, or a server route) and throw otherwise.
- **Isomorphic**: `getCookie`/`setCookie` from the root of `@tanstack/react-start`. The same call works on the server _and_ in the browser — pick this when a component or a shared helper needs to read/write a cookie regardless of which side it renders on.

## Isomorphic `getCookie`/`setCookie`

```tsx
import { getCookie, setCookie } from '@tanstack/react-start'
```

These are built with [`createIsomorphicFn`](./environment-functions): on the server they delegate to the same request-scoped cookie handling as the `/server` versions; in the browser they read/write `document.cookie` directly.

```tsx
import * as React from 'react'
import { getCookie, setCookie } from '@tanstack/react-start'

export const getServerNow = createServerFn().handler(async () => {
// Runs on the server: reads the incoming request's `Cookie` header.
const theme = getCookie('theme') ?? 'light'
return theme
})

function ThemeToggle() {
const [theme, setTheme] = React.useState(() => getCookie('theme') ?? 'light')

return (
<button
onClick={() => {
const next = theme === 'light' ? 'dark' : 'light'
// Runs in the browser: writes `document.cookie` directly.
setCookie('theme', next, { path: '/', maxAge: 60 * 60 * 24 * 365 })
setTheme(next)
}}
>
Switch to {theme === 'light' ? 'dark' : 'light'}
</button>
)
}
```

## `HttpOnly` only works on the server

`setCookie`'s `options` accept the same [`CookieSerializeOptions`](https://github.com/unjs/cookie-es) as the server-only version, including `httpOnly`. But `HttpOnly` is a protection against client-side JavaScript reading the cookie — so it can only be set from the server.

If `setCookie` runs in the browser (the client branch) with `httpOnly: true`, the browser doesn't just ignore the flag — it silently discards the entire cookie write. `setCookie` logs a `console.warn` in development to help catch this, but there's no way to recover the write: if a cookie needs `HttpOnly`, set it from a server function or request middleware, not from client code.

```tsx
// ❌ Runs in the browser: the cookie is never actually set.
setCookie('session', token, { httpOnly: true })

// ✅ Runs on the server: `HttpOnly` is honored.
export const login = createServerFn().handler(async () => {
setCookie('session', token, { httpOnly: true })
})
```

## When to use the server-only versions instead

Reach for `getCookie`/`setCookie`/`getCookies`/`deleteCookie` from `@tanstack/react-start/server` when the code only ever runs on the server (a server function handler, request middleware, or a server route) and you don't need it to also work in the browser — for example, reading a locale cookie inside request middleware, as shown in [Hydration Errors](./hydration-errors). Calling the server-only versions from client code throws instead of silently doing something unexpected.
2 changes: 1 addition & 1 deletion docs/start/framework/react/guide/hydration-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,4 @@ export const Route = createFileRoute('/unstable')({
- **Use Selective SSR** when server HTML cannot be stable
- **Avoid blind suppression**; use `suppressHydrationWarning` sparingly

See also: [Execution Model](./execution-model.md), [Code Execution Patterns](./code-execution-patterns.md), [Selective SSR](./selective-ssr.md), [Server Functions](./server-functions.md)
See also: [Execution Model](./execution-model.md), [Code Execution Patterns](./code-execution-patterns.md), [Selective SSR](./selective-ssr.md), [Server Functions](./server-functions.md), [Cookies](./cookies.md)
65 changes: 65 additions & 0 deletions docs/start/framework/solid/guide/cookies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
id: cookies
title: Cookies
---

Start ships two ways to read and write cookies:

- **Server-only**: `getCookie`/`setCookie`/`getCookies`/`deleteCookie` from `@tanstack/solid-start/server`. These only work inside an active server request (a server function, request middleware, or a server route) and throw otherwise.
- **Isomorphic**: `getCookie`/`setCookie` from the root of `@tanstack/solid-start`. The same call works on the server _and_ in the browser — pick this when a component or a shared helper needs to read/write a cookie regardless of which side it renders on.

## Isomorphic `getCookie`/`setCookie`

```tsx
import { getCookie, setCookie } from '@tanstack/solid-start'
```

These are built with [`createIsomorphicFn`](./environment-functions): on the server they delegate to the same request-scoped cookie handling as the `/server` versions; in the browser they read/write `document.cookie` directly.

```tsx
import { createSignal } from 'solid-js'
import { getCookie, setCookie } from '@tanstack/solid-start'

export const getServerNow = createServerFn().handler(async () => {
// Runs on the server: reads the incoming request's `Cookie` header.
const theme = getCookie('theme') ?? 'light'
return theme
})

function ThemeToggle() {
const [theme, setTheme] = createSignal(getCookie('theme') ?? 'light')

return (
<button
onClick={() => {
const next = theme() === 'light' ? 'dark' : 'light'
// Runs in the browser: writes `document.cookie` directly.
setCookie('theme', next, { path: '/', maxAge: 60 * 60 * 24 * 365 })
setTheme(next)
}}
>
Switch to {theme() === 'light' ? 'dark' : 'light'}
</button>
)
}
```

## `HttpOnly` only works on the server

`setCookie`'s `options` accept the same [`CookieSerializeOptions`](https://github.com/unjs/cookie-es) as the server-only version, including `httpOnly`. But `HttpOnly` is a protection against client-side JavaScript reading the cookie — so it can only be set from the server.

If `setCookie` runs in the browser (the client branch) with `httpOnly: true`, the browser doesn't just ignore the flag — it silently discards the entire cookie write. `setCookie` logs a `console.warn` in development to help catch this, but there's no way to recover the write: if a cookie needs `HttpOnly`, set it from a server function or request middleware, not from client code.

```tsx
// ❌ Runs in the browser: the cookie is never actually set.
setCookie('session', token, { httpOnly: true })

// ✅ Runs on the server: `HttpOnly` is honored.
export const login = createServerFn().handler(async () => {
setCookie('session', token, { httpOnly: true })
})
```

## When to use the server-only versions instead

Reach for `getCookie`/`setCookie`/`getCookies`/`deleteCookie` from `@tanstack/solid-start/server` when the code only ever runs on the server (a server function handler, request middleware, or a server route) and you don't need it to also work in the browser — for example, reading a locale cookie inside request middleware, as shown in [Hydration Errors](./hydration-errors). Calling the server-only versions from client code throws instead of silently doing something unexpected.
2 changes: 1 addition & 1 deletion docs/start/framework/solid/guide/hydration-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,4 @@ export const Route = createFileRoute('/unstable')({
- **Use `<ClientOnly>`/`<NoHydration>`** for inherently dynamic UI
- **Use Selective SSR** when server HTML cannot be stable

See also: [Execution Model](./execution-model.md), [Code Execution Patterns](./code-execution-patterns.md), [Selective SSR](./selective-ssr.md), [Server Functions](./server-functions.md)
See also: [Execution Model](./execution-model.md), [Code Execution Patterns](./code-execution-patterns.md), [Selective SSR](./selective-ssr.md), [Server Functions](./server-functions.md), [Cookies](./cookies.md)
4 changes: 3 additions & 1 deletion packages/react-start/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
],
"scripts": {
"clean": "rimraf ./dist && rimraf ./coverage",
"test": "pnpm test:build",
"test": "pnpm test:build && pnpm test:unit",
"test:unit": "vitest",
"test:unit:dev": "vitest --watch",
"test:build": "publint --strict && attw --ignore-rules no-resolution --pack .",
"build": "vite build && vite build -c vite.config.server-entry.ts"
},
Expand Down
33 changes: 33 additions & 0 deletions packages/react-start/src/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createCookieFns } from '@tanstack/start-client-core/cookies'
import {
getCookie as getServerCookie,
setCookie as setServerCookie,
} from '@tanstack/start-server-core'

const cookieFns = createCookieFns({
getCookie: getServerCookie,
setCookie: setServerCookie,
})

/**
* Get a cookie value by name. Works on both the server (reads the current
* request's `Cookie` header) and the client (reads `document.cookie`).
* @param name Name of the cookie to get
* @returns Value of the cookie, or `undefined` if not present
* ```ts
* const authorization = getCookie('Authorization')
* ```
*/
export const getCookie = cookieFns.getCookie

/**
* Set a cookie value by name. Works on both the server (sets a `Set-Cookie`
* header on the current response) and the client (writes `document.cookie`).
* @param name Name of the cookie to set
* @param value Value of the cookie to set
* @param options Options for serializing the cookie
* ```ts
* setCookie('Authorization', '1234567')
* ```
*/
export const setCookie = cookieFns.setCookie
1 change: 1 addition & 0 deletions packages/react-start/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { useServerFn } from './useServerFn'
export { getCookie, setCookie } from './cookies'
export * from '@tanstack/start-client-core'
// Explicit re-exports shadow `export *` above so these public-API names are
// registered on the namespace at link time (via Vite SSR's `defineExport`
Expand Down
39 changes: 39 additions & 0 deletions packages/react-start/tests/cookies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const getServerCookieMock = vi.fn<(name: string) => string | undefined>()
const setServerCookieMock = vi.fn()

vi.mock('@tanstack/start-server-core', () => ({
getCookie: getServerCookieMock,
setCookie: setServerCookieMock,
}))

const { getCookie, setCookie } = await import('../src/cookies')

// The cookie logic itself (client-side parsing/serialization, the httpOnly
// warning, and createCookieFns' delegation behavior) is covered by
// start-client-core's own tests. This only verifies that this package wires
// its getCookie/setCookie up to the right start-server-core functions.
describe('getCookie/setCookie', () => {
beforeEach(() => {
getServerCookieMock.mockReset()
setServerCookieMock.mockReset()
})

it('delegates getCookie to start-server-core', () => {
getServerCookieMock.mockReturnValue('server-value')

expect(getCookie('token')).toBe('server-value')
expect(getServerCookieMock).toHaveBeenCalledExactlyOnceWith('token')
})

it('delegates setCookie to start-server-core', () => {
setCookie('token', 'abc123', { path: '/' })

expect(setServerCookieMock).toHaveBeenCalledExactlyOnceWith(
'token',
'abc123',
{ path: '/' },
)
})
})
4 changes: 3 additions & 1 deletion packages/solid-start/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
],
"scripts": {
"clean": "rimraf ./dist && rimraf ./coverage",
"test": "pnpm test:build",
"test": "pnpm test:build && pnpm test:unit",
"test:unit": "vitest",
"test:unit:dev": "vitest --watch",
"test:build": "publint --strict && attw --ignore-rules no-resolution --pack .",
"build": "vite build && vite build -c vite.config.server-entry.ts"
},
Expand Down
33 changes: 33 additions & 0 deletions packages/solid-start/src/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createCookieFns } from '@tanstack/start-client-core/cookies'
import {
getCookie as getServerCookie,
setCookie as setServerCookie,
} from '@tanstack/start-server-core'

const cookieFns = createCookieFns({
getCookie: getServerCookie,
setCookie: setServerCookie,
})

/**
* Get a cookie value by name. Works on both the server (reads the current
* request's `Cookie` header) and the client (reads `document.cookie`).
* @param name Name of the cookie to get
* @returns Value of the cookie, or `undefined` if not present
* ```ts
* const authorization = getCookie('Authorization')
* ```
*/
export const getCookie = cookieFns.getCookie

/**
* Set a cookie value by name. Works on both the server (sets a `Set-Cookie`
* header on the current response) and the client (writes `document.cookie`).
* @param name Name of the cookie to set
* @param value Value of the cookie to set
* @param options Options for serializing the cookie
* ```ts
* setCookie('Authorization', '1234567')
* ```
*/
export const setCookie = cookieFns.setCookie
1 change: 1 addition & 0 deletions packages/solid-start/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { useServerFn } from './useServerFn'
export { getCookie, setCookie } from './cookies'
export * from '@tanstack/start-client-core'
// Explicit re-exports shadow `export *` above so these public-API names are
// registered on the namespace at link time (via Vite SSR's `defineExport`
Expand Down
39 changes: 39 additions & 0 deletions packages/solid-start/tests/cookies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const getServerCookieMock = vi.fn<(name: string) => string | undefined>()
const setServerCookieMock = vi.fn()

vi.mock('@tanstack/start-server-core', () => ({
getCookie: getServerCookieMock,
setCookie: setServerCookieMock,
}))

const { getCookie, setCookie } = await import('../src/cookies')

// The cookie logic itself (client-side parsing/serialization, the httpOnly
// warning, and createCookieFns' delegation behavior) is covered by
// start-client-core's own tests. This only verifies that this package wires
// its getCookie/setCookie up to the right start-server-core functions.
describe('getCookie/setCookie', () => {
beforeEach(() => {
getServerCookieMock.mockReset()
setServerCookieMock.mockReset()
})

it('delegates getCookie to start-server-core', () => {
getServerCookieMock.mockReturnValue('server-value')

expect(getCookie('token')).toBe('server-value')
expect(getServerCookieMock).toHaveBeenCalledExactlyOnceWith('token')
})

it('delegates setCookie to start-server-core', () => {
setCookie('token', 'abc123', { path: '/' })

expect(setServerCookieMock).toHaveBeenCalledExactlyOnceWith(
'token',
'abc123',
{ path: '/' },
)
})
})
7 changes: 7 additions & 0 deletions packages/start-client-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@
"default": "./dist/esm/client-rpc/index.js"
}
},
"./cookies": {
"import": {
"types": "./dist/esm/cookies.d.ts",
"default": "./dist/esm/cookies.js"
}
},
"./hydration": {
"import": {
"types": "./dist/esm/hydration.d.ts",
Expand Down Expand Up @@ -109,6 +115,7 @@
"@tanstack/router-core": "workspace:*",
"@tanstack/start-fn-stubs": "workspace:*",
"@tanstack/start-storage-context": "workspace:*",
"cookie-es": "^3.0.0",
"seroval": "^1.5.4"
},
"devDependencies": {
Expand Down
Loading