[codex] Add TanStack Start SDK integration (#1399)

## Summary

- Adds the generated `@stackframe/tanstack-start` workspace package
registration.
- Adds TanStack Start platform macros/dependencies to the SDK template
and generator.
- Adds TanStack Start cookie/token-store support plus the handler SSR
guard needed by Start.

## Scope

This intentionally excludes Dashboard V2 routes, hooks, components, app
shell logic, and dashboard API type additions. Those stay in the
existing dashboard PR/branch.

## Validation

- `pnpm install --lockfile-only --ignore-scripts`
- `pnpm install --ignore-scripts`
- `pnpm -C packages/template lint
src/components-page/stack-handler-client.tsx src/lib/cookie.ts
src/lib/stack-app/apps/implementations/client-app-impl.ts`

Package typecheck was attempted with `pnpm -C packages/template
typecheck`, but the clean worktree lacks generated package declaration
outputs for workspace dependencies such as `@stackframe/stack-shared`
and `@stackframe/stack-ui`. Per repo instructions, package
builds/codegen are not run by agents.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* TanStack Start integration: published SDK package, example demo app,
dashboard onboarding flow, framework-aware CTAs/docs, and a
TanStack-specific provider for client-only auth routes.
* Improved client/server auth: safer runtime guards and consistent
cookie/token-store behavior across SSR and client.

* **Documentation**
* New Integrations guide and expanded getting-started/setup docs with
TanStack Start examples and env/key guidance.

* **Chores**
* Template, build, tooling, and demo config updates to support the new
platform.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Mantra 2026-05-08 10:59:16 -07:00 committed by GitHub
parent acc646cb0b
commit 68ae6d1f1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 1535 additions and 44 deletions

2
.gitignore vendored
View File

@ -140,10 +140,12 @@ packages/js/*
packages/react/*
packages/next/*
packages/stack/*
packages/tanstack-start/*
!packages/js/package.json
!packages/react/package.json
!packages/next/package.json
!packages/stack/package.json
!packages/tanstack-start/package.json
# claude code
.claude/scheduled_tasks.lock

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,7 +1,7 @@
'use client';
import { CodeBlock } from '@/components/code-block';
import { APIEnvKeys, NextJsEnvKeys } from '@/components/env-keys';
import { APIEnvKeys, NextJsEnvKeys, ViteEnvKeys } from '@/components/env-keys';
import { InlineCode } from '@/components/inline-code';
import { StyledLink } from '@/components/link';
import { CopyPromptButton, Tabs, TabsContent, TabsList, TabsTrigger, Typography, cn } from "@/components/ui";
@ -27,22 +27,58 @@ const nameClasses = "text-green-600 dark:text-green-500";
const INSTALL_COMMAND_BY_FRAMEWORK = {
nextjs: 'npx @stackframe/stack-cli@latest init',
tanstackStart: 'npm install @stackframe/tanstack-start',
react: 'npm install @stackframe/react',
javascript: 'npm install @stackframe/js',
python: 'pip install requests',
} as const;
const buildInstallPrompt = (command: string) => deindent`
type SetupFramework = keyof typeof INSTALL_COMMAND_BY_FRAMEWORK;
const TANSTACK_START_SETUP_PROMPT = deindent`
Please set up Stack Auth in my TanStack Start app.
1. Install the alpha TanStack Start package:
npm install @stackframe/tanstack-start
2. Configure the app with these environment variables:
VITE_STACK_PROJECT_ID=<project-id>
STACK_SECRET_SERVER_KEY=<secret-server-key>
3. Create a StackClientApp using @stackframe/tanstack-start with:
- projectId: import.meta.env.VITE_STACK_PROJECT_ID
- tokenStore: "cookie"
- redirectMethod: "window"
4. Wrap the TanStack Start root route with StackProvider and StackTheme.
5. Add a /handler/$ route using StackHandler. The handler route must set ssr: false and pass location={pathname} from useLocation().
Use only the environment variables listed above.
After it finishes, verify that the Stack Auth MCP server is registered in your AI client config name: \`stack-auth\`, transport: \`http\`, URL: \`https://mcp.stack-auth.com/\`. If it is not registered, please add it manually so you have live access to Stack Auth docs and APIs.
`;
const buildInstallPrompt = (framework: SetupFramework) => {
if (framework === "tanstackStart") {
return TANSTACK_START_SETUP_PROMPT;
}
const command = INSTALL_COMMAND_BY_FRAMEWORK[framework];
return deindent`
Please run the following command in my project's terminal:
${command}
After it finishes, verify that the Stack Auth MCP server is registered in your AI client config name: \`stack-auth\`, transport: \`http\`, URL: \`https://mcp.stack-auth.com/mcp\`. The command above should handle this automatically; if for any reason it didn't, please add the MCP server manually so you have live access to Stack Auth docs and APIs.
`;
};
export default function SetupPage(props: { toMetrics: () => void }) {
const adminApp = useAdminApp();
const [selectedFramework, setSelectedFramework] = useState<'nextjs' | 'react' | 'javascript' | 'python'>('nextjs');
const [selectedFramework, setSelectedFramework] = useState<'nextjs' | 'tanstackStart' | 'react' | 'javascript' | 'python'>('nextjs');
const [keys, setKeys] = useState<{ projectId: string, publishableClientKey?: string, secretServerKey: string } | null>(null);
const projectConfig = adminApp.useProject().useConfig();
const requirePublishableClientKey = projectConfig.project.requirePublishableClientKey;
@ -220,6 +256,142 @@ export default function SetupPage(props: { toMetrics: () => void }) {
}
];
const tanstackStartSteps = [
{
step: 2,
title: "Install Stack Auth",
content: <>
<Typography>
In a new or existing TanStack Start project, install the alpha Stack Auth package:
</Typography>
<CodeBlock
language="bash"
content={`npm install @stackframe/tanstack-start`}
customRender={
<div className="p-4 font-mono text-sm">
<span className={commandClasses}>npm install</span> <span className={nameClasses}>@stackframe/tanstack-start</span>
</div>
}
title="Terminal"
icon="terminal"
/>
</>
},
{
step: 3,
title: "Create Keys",
content: <>
<Typography>
Put these keys in your TanStack Start environment file.
</Typography>
<StackAuthKeys keys={keys} onGenerateKeys={onGenerateKeys} type="vite" />
</>
},
{
step: 4,
title: "Create stack/client.ts file",
content: <>
<Typography>
Create a new file called <InlineCode>src/stack/client.ts</InlineCode> and initialize Stack Auth with cookie storage.
</Typography>
<CodeBlock
language="tsx"
content={deindent`
import { StackClientApp } from "@stackframe/tanstack-start";
export const stackClientApp = new StackClientApp({
projectId: import.meta.env.VITE_STACK_PROJECT_ID,
tokenStore: "cookie",
redirectMethod: "window",
});
`}
title="src/stack/client.ts"
icon="code"
/>
</>
},
{
step: 5,
title: "Update the root route",
content: <>
<Typography>
Wrap your TanStack Start root route with <InlineCode>StackProvider</InlineCode> and <InlineCode>StackTheme</InlineCode>.
</Typography>
<CodeBlock
language="tsx"
maxHeight={300}
content={deindent`
import { StackProvider, StackTheme } from "@stackframe/tanstack-start";
import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router";
import { stackClientApp } from "../stack/client";
export const Route = createRootRoute({
component: RootComponent,
shellComponent: RootDocument,
});
function RootComponent() {
return (
<StackProvider app={stackClientApp}>
<StackTheme>
<Outlet />
</StackTheme>
</StackProvider>
);
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
`}
title="src/routes/__root.tsx"
icon="code"
/>
</>
},
{
step: 6,
title: "Add the handler route",
content: <>
<Typography>
Create a splat route for Stack Auth&apos;s built-in auth pages.
</Typography>
<CodeBlock
language="tsx"
content={deindent`
import { StackHandler } from "@stackframe/tanstack-start";
import { createFileRoute, useLocation } from "@tanstack/react-router";
export const Route = createFileRoute("/handler/$")({
ssr: false,
component: HandlerPage,
});
function HandlerPage() {
const { pathname } = useLocation();
return <StackHandler fullPage location={pathname} />;
}
`}
title="src/routes/handler/$.tsx"
icon="code"
/>
<Typography>
If you start your TanStack Start app and navigate to <StyledLink href="http://localhost:3000/handler/sign-up">http://localhost:3000/handler/sign-up</StyledLink>, you will see the sign-up page.
</Typography>
</>
},
];
const javascriptSteps = [
{
step: 2,
@ -480,7 +652,7 @@ export default function SetupPage(props: { toMetrics: () => void }) {
<CopyPromptButton
variant="outline"
size="sm"
content={buildInstallPrompt(INSTALL_COMMAND_BY_FRAMEWORK[selectedFramework])}
content={buildInstallPrompt(selectedFramework)}
>
<SparkleIcon className="w-4 h-4 mr-2 text-purple-500 dark:text-purple-400" weight="fill" />
Copy prompt
@ -500,6 +672,11 @@ export default function SetupPage(props: { toMetrics: () => void }) {
name: 'Next.js',
reverseIfDark: true,
imgSrc: '/next-logo.svg',
}, {
id: 'tanstackStart',
name: 'TanStack Start',
reverseIfDark: false,
imgSrc: '/tanstack-start-logo.png',
}, {
id: 'react',
name: 'React',
@ -538,6 +715,7 @@ export default function SetupPage(props: { toMetrics: () => void }) {
</div>,
},
...(selectedFramework === 'nextjs' ? nextJsSteps : []),
...(selectedFramework === 'tanstackStart' ? tanstackStartSteps : []),
...(selectedFramework === 'react' ? reactSteps : []),
...(selectedFramework === 'javascript' ? javascriptSteps : []),
...(selectedFramework === 'python' ? pythonSteps : []),
@ -638,7 +816,7 @@ function GlobeIllustrationInner() {
function StackAuthKeys(props: {
keys: { projectId: string, publishableClientKey?: string, secretServerKey: string } | null,
onGenerateKeys: () => Promise<void>,
type: 'next' | 'raw',
type: 'next' | 'vite' | 'raw',
}) {
return (
<div className="w-full border rounded-xl p-8 gap-4 flex flex-col">
@ -650,6 +828,11 @@ function StackAuthKeys(props: {
publishableClientKey={props.keys.publishableClientKey}
secretServerKey={props.keys.secretServerKey}
/>
) : props.type === 'vite' ? (
<ViteEnvKeys
projectId={props.keys.projectId}
secretServerKey={props.keys.secretServerKey}
/>
) : (
<APIEnvKeys
projectId={props.keys.projectId}

View File

@ -4,7 +4,7 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a
import { AppStoreEntry } from "@/components/app-store-entry";
import { useRouter } from "@/components/router";
import { useUpdateConfig } from "@/lib/config-update";
import { ALL_APPS_FRONTEND, getAppPath, isSubApp, type AppId } from "@/lib/apps-frontend";
import { ALL_APPS_FRONTEND, getAppPath, getDocumentationHref, isSubApp, type AppId } from "@/lib/apps-frontend";
import { isAppEnabled } from "@/lib/apps-utils";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
@ -28,6 +28,8 @@ export default function AppDetailsPageClient({ appId }: { appId: AppId }) {
const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId];
const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId);
const appPath = getAppPath(project.id, appFrontend);
const documentationHref = getDocumentationHref(appFrontend);
const appDestination = documentationHref ?? appPath;
const subAppDestinationPath = parentAppFrontend == null
? null
: parentAppEnabled
@ -40,11 +42,19 @@ export default function AppDetailsPageClient({ appId }: { appId: AppId }) {
configUpdate: { [`apps.installed.${appId}.enabled`]: true },
pushable: true,
});
router.push(appPath);
if (documentationHref != null) {
window.location.href = documentationHref;
} else {
router.push(appPath);
}
};
const handleOpen = () => {
router.push(subAppDestinationPath ?? appPath);
if (documentationHref != null) {
window.location.href = documentationHref;
} else {
router.push(subAppDestinationPath ?? appDestination);
}
};
const handleDisable = async () => {

View File

@ -58,9 +58,11 @@ type AppSection = {
items: {
name: string,
href: string,
external?: boolean,
match: (fullUrl: URL) => boolean,
}[],
firstItemHref?: string,
firstItemExternal?: boolean,
};
type BottomItem = {
@ -209,6 +211,7 @@ function NavItem({
if (isCollapsed) {
// For sections, navigate to the first item when collapsed
const collapsedHref = isSection && item.firstItemHref ? item.firstItemHref : href;
const collapsedTarget = isSection && item.firstItemExternal ? "_blank" : undefined;
return (
<div className="flex justify-center">
@ -226,7 +229,7 @@ function NavItem({
: "hover:bg-white/40 dark:hover:bg-background/60 text-muted-foreground hover:text-foreground"
)}
>
<Link href={collapsedHref ?? "#"} onClick={onClick}>
<Link href={collapsedHref ?? "#"} target={collapsedTarget} onClick={onClick}>
<IconComponent className={iconClasses} />
</Link>
</Button>
@ -351,6 +354,7 @@ function NavSubItem({
return (
<Link
href={href}
target={item.external ? "_blank" : undefined}
onClick={onClick}
className={cn(
"group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-150 hover:transition-none",
@ -404,6 +408,7 @@ function AppNavItem({
const items = navigableFrontend.navigationItems.map((navItem) => ({
name: navItem.displayName,
href: getItemPath(projectId, navigableFrontend, navItem),
external: navItem.external,
match: (fullUrl: URL) => testItemPath(projectId, navigableFrontend, navItem, fullUrl),
}));
return {
@ -413,6 +418,7 @@ function AppNavItem({
href: getAppPath(projectId, appFrontend),
icon: appFrontend.icon,
firstItemHref: items[0]?.href,
firstItemExternal: items[0]?.external,
};
}, [app.displayName, appId, appFrontend, projectId]);

View File

@ -1,7 +1,7 @@
import { useAdminApp, useProjectId } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { useRouter } from "@/components/router";
import { Button, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui";
import { ALL_APPS_FRONTEND, AppFrontend, getAppPath, isSubApp } from "@/lib/apps-frontend";
import { ALL_APPS_FRONTEND, AppFrontend, getAppPath, getDocumentationHref, isSubApp } from "@/lib/apps-frontend";
import { isAppEnabled } from "@/lib/apps-utils";
import { useUpdateConfig } from "@/lib/config-update";
import { CheckIcon, DotsThreeVerticalIcon } from "@phosphor-icons/react";
@ -220,6 +220,7 @@ export function AppListItem({
const isEnabled = isAppEnabled(config.apps.installed, appId);
const appPath = getAppPath(project.id, appFrontend);
const appDestinationPath = getDocumentationHref(appFrontend) ?? appPath;
const appDetailsPath = `/projects/${project.id}/apps/${appId}`;
const router = useRouter();
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
@ -249,7 +250,7 @@ export function AppListItem({
return (
<Link
href={parentDestinationPath ?? (isEnabled ? appPath : appDetailsPath)}
href={parentDestinationPath ?? (isEnabled ? appDestinationPath : appDetailsPath)}
className={cn(
"flex items-center gap-3 p-3 rounded-lg transition-all",
"hover:bg-gray-50 dark:hover:bg-gray-800/50",

View File

@ -2,7 +2,7 @@
import { AppIcon } from "@/components/app-square";
import { Badge, Button, Dialog, DialogContent, DialogTitle, ScrollArea, cn } from "@/components/ui";
import { ALL_APPS_FRONTEND, isSubApp, type AppId } from "@/lib/apps-frontend";
import { ALL_APPS_FRONTEND, getDocumentationHref, isSubApp, type AppId } from "@/lib/apps-frontend";
import { ArrowRightIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, LightningIcon, ShieldCheckIcon, XIcon } from "@phosphor-icons/react";
import { ALL_APPS, ALL_APP_TAGS } from "@stackframe/stack-shared/dist/apps/apps-config";
import Image from "next/image";
@ -25,6 +25,7 @@ export function AppStoreEntry({
}) {
const app = ALL_APPS[appId];
const appFrontend = ALL_APPS_FRONTEND[appId];
const isDocumentationBackedApp = getDocumentationHref(appFrontend) != null;
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
const screenshotContainerRef = useRef<HTMLDivElement>(null);
@ -154,7 +155,7 @@ export function AppStoreEntry({
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
>
<ArrowRightIcon className="w-4 h-4 mr-2" />
Open App
{isDocumentationBackedApp ? "Open Docs" : "Open App"}
</Button>
{onDisable && (
<Button

View File

@ -127,3 +127,27 @@ export function NextJsEnvKeys(props: {
/>
);
}
export function ViteEnvKeys(props: {
projectId: string,
secretServerKey?: string,
}) {
const envFileContent = Object.entries({
VITE_STACK_API_URL: getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') === "https://api.stack-auth.com" ? undefined : getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL'),
VITE_STACK_PROJECT_ID: props.projectId,
STACK_SECRET_SERVER_KEY: props.secretServerKey,
})
.filter(([, value]) => value != null)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
return (
<CopyField
type="textarea"
monospace
height={envFileContent.split("\n").length * 26}
value={envFileContent}
fixedSize
/>
);
}

View File

@ -1,11 +1,12 @@
import { Link } from "@/components/link";
import { ChartLineIcon, ClipboardTextIcon, CreditCardIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, RocketIcon, ShieldCheckIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react";
import { ChartLineIcon, ClipboardTextIcon, CodeIcon, CreditCardIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, RocketIcon, ShieldCheckIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react";
import { StackAdminApp } from "@stackframe/stack";
import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
import { getRelativePart, isChildUrl } from "@stackframe/stack-shared/dist/utils/urls";
import Image, { StaticImageData } from "next/image";
import ConvexLogo from "../../public/convex-logo.png";
import NeonLogo from "../../public/neon-logo.png";
import TanStackStartLogo from "../../public/tanstack-start-logo.png";
import VercelLogo from "../../public/vercel-logo.svg";
export type AppId = keyof typeof ALL_APPS;
@ -25,6 +26,7 @@ type BreadcrumbDefinition = {
type AppNavigationItem = {
displayName: string,
href: string,
external?: boolean,
matchPath?: (relativePart: string) => boolean,
getBreadcrumbItems?: (stackAdminApp: StackAdminApp<false>, relativePart: string) => Promise<BreadcrumbDefinition | null | undefined>,
};
@ -33,6 +35,7 @@ export type AppFrontend = {
icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>,
logo?: React.FunctionComponent<{}>,
href: string,
documentationHref?: string,
screenshots: (string | StaticImageData)[],
storeDescription: JSX.Element,
} & (
@ -57,12 +60,24 @@ export function isSubApp(appFrontend: AppFrontend): appFrontend is SubAppFronten
return "parentAppId" in appFrontend;
}
export function getDocumentationHref(appFrontend: AppFrontend): string | null {
return "documentationHref" in appFrontend ? appFrontend.documentationHref ?? null : null;
}
export function getAppPath(projectId: string, appFrontend: AppFrontend) {
const url = new URL(appFrontend.href, `${DUMMY_ORIGIN}/projects/${projectId}/`);
return getRelativePart(url);
}
function isExternalHref(href: string) {
return href.startsWith("http://") || href.startsWith("https://");
}
export function getItemPath(projectId: string, appFrontend: NavigableAppFrontend, item: AppNavigationItem) {
if (item.external || isExternalHref(item.href)) {
return item.href;
}
const url = new URL(item.href, new URL(appFrontend.href, `${DUMMY_ORIGIN}/projects/${projectId}/`) + "/");
return getRelativePart(url);
}
@ -82,6 +97,10 @@ export function testAppPath(projectId: string, appFrontend: AppFrontend, fullUrl
}
export function testItemPath(projectId: string, appFrontend: NavigableAppFrontend, item: AppNavigationItem, fullUrl: URL) {
if (item.external || isExternalHref(item.href)) {
return false;
}
if (item.matchPath) return item.matchPath(getRelativePart(fullUrl));
const url = new URL(getItemPath(projectId, appFrontend, item), fullUrl);
@ -333,6 +352,27 @@ export const ALL_APPS_FRONTEND = {
screenshots: getScreenshots('vercel', 2),
storeDescription: <>Deploy your Stack Auth project to <Link href="https://vercel.com" target="_blank">Vercel</Link> with the Vercel x Stack Auth integration.</>,
},
"tanstack-start": {
icon: CodeIcon,
logo: () => <Image src={TanStackStartLogo} alt="TanStack Start logo" />,
href: "tanstack-start",
documentationHref: "https://docs.stack-auth.com/guides/integrations/tanstack-start/overview",
navigationItems: [
{
displayName: "Docs",
href: "https://docs.stack-auth.com/guides/integrations/tanstack-start/overview",
external: true,
},
],
screenshots: [],
storeDescription: (
<>
<p>TanStack Start integration adds Stack Auth to full-stack React apps built with TanStack Router and Vite.</p>
<p>Install the alpha `@stackframe/tanstack-start` package, wire the Stack provider into your root route, and mount the built-in auth handler pages under your app origin.</p>
<p>The dashboard sidebar entry opens the integration docs so your team can jump back to setup instructions from the project.</p>
</>
),
},
analytics: {
icon: ChartLineIcon,
href: "analytics",
@ -445,4 +485,3 @@ async function getEmailDraftBreadcrumbItems(stackAdminApp: StackAdminApp<false>,
},
];
}

View File

@ -182,6 +182,16 @@
],
importance: 2,
},
{
name: "TanStack Start demo",
portSuffix: "43",
description: [
"Src: ./examples/tanstack-start-demo",
"Alpha SDK integration demo",
],
img: "https://tanstack.com/favicon.ico",
importance: 2,
},
{
name: "Docs",
portSuffix: "26",

View File

@ -121,6 +121,7 @@
{
"group": "Integrations",
"pages": [
"guides/integrations/tanstack-start/overview",
"guides/integrations/supabase/overview",
"guides/integrations/convex/overview",
"guides/integrations/vercel/overview"

View File

@ -0,0 +1,166 @@
---
title: TanStack Start
description: Add Stack Auth to a TanStack Start app.
---
<Info>
The `@stackframe/tanstack-start` package is currently alpha. Pin exact package versions before shipping production apps.
</Info>
TanStack Start is a full-stack React framework built on TanStack Router and Vite. Stack Auth's TanStack Start package provides the same auth components and hooks as the React SDK, with cookie handling wired for TanStack Start.
## Setup
<Steps>
<Step title="Create or open a TanStack Start app">
If you do not have a TanStack Start app yet, create one with the TanStack CLI:
```bash title="Terminal"
npx @tanstack/cli@latest create
```
TanStack also publishes official examples if you prefer to start from a working project.
</Step>
<Step title="Install Stack Auth">
Install the alpha TanStack Start package:
```bash title="Terminal"
npm install @stackframe/tanstack-start
```
</Step>
<Step title="Create Stack Auth keys">
In the [Stack Auth dashboard](https://app.stack-auth.com/projects), create a project and add these variables to your TanStack Start environment:
```bash title=".env"
VITE_STACK_PROJECT_ID=<your-project-id>
STACK_SECRET_SERVER_KEY=<your-secret-server-key>
```
Keep `STACK_SECRET_SERVER_KEY` server-only. Do not expose it to client code.
</Step>
<Step title="Create a Stack client app">
Create a Stack client app with cookie storage:
```ts title="src/stack/client.ts"
import { StackClientApp } from "@stackframe/tanstack-start";
export const stackClientApp = new StackClientApp({
projectId: import.meta.env.VITE_STACK_PROJECT_ID,
tokenStore: "cookie",
redirectMethod: "window",
});
```
</Step>
<Step title="Wrap the root route">
Add `StackProvider` and `StackTheme` around your route outlet:
```tsx title="src/routes/__root.tsx"
import { StackProvider, StackTheme } from "@stackframe/tanstack-start";
import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router";
import { stackClientApp } from "../stack/client";
export const Route = createRootRoute({
component: RootComponent,
shellComponent: RootDocument,
});
function RootComponent() {
return (
<StackProvider app={stackClientApp}>
<StackTheme>
<Outlet />
</StackTheme>
</StackProvider>
);
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
```
</Step>
<Step title="Add the auth handler route">
Create a splat route at `/handler/*` for Stack Auth's built-in pages:
```tsx title="src/routes/handler/$.tsx"
import { StackHandler } from "@stackframe/tanstack-start";
import { createFileRoute, useLocation } from "@tanstack/react-router";
export const Route = createFileRoute("/handler/$")({
ssr: false,
component: HandlerPage,
});
function HandlerPage() {
const { pathname } = useLocation();
return <StackHandler fullPage location={pathname} />;
}
```
</Step>
<Step title="Use auth in routes">
Use Stack hooks from inside components rendered under the provider:
```tsx title="src/routes/index.tsx"
import { useUser } from "@stackframe/tanstack-start";
import { createFileRoute, Link } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: HomePage,
});
function HomePage() {
const user = useUser();
if (!user) {
return <Link to="/handler/sign-in">Sign in</Link>;
}
return <div>Signed in as {user.primaryEmail}</div>;
}
```
Routes that use browser redirects should render on the client:
```tsx title="src/routes/protected.tsx"
import { useUser } from "@stackframe/tanstack-start";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/protected")({
ssr: false,
component: ProtectedPage,
});
function ProtectedPage() {
const user = useUser({ or: "redirect" });
return <div>Welcome, {user.primaryEmail}</div>;
}
```
</Step>
</Steps>
## Notes
- The handler route must stay under the same origin as your app when using `tokenStore: "cookie"`.
- Render the handler route on the client (`ssr: false`) because built-in auth pages read browser location state.
- Render routes that rely on `useUser({ or: "redirect" })` on the client (`ssr: false`) when using `redirectMethod: "window"`.
- Use `redirectMethod: "window"` unless you explicitly wire a TanStack Router navigation adapter.
- If you change auth routes, configure the matching `urls` on `StackClientApp`.
- For server-only logic, use TanStack Start server functions and keep `STACK_SECRET_SERVER_KEY` out of client modules.
For TanStack Start framework details, see the [TanStack Start quick start](https://tanstack.com/start/latest/docs/framework/react/quick-start) and [server functions guide](https://tanstack.com/start/latest/docs/framework/react/guide/server-functions).

View File

@ -2,7 +2,7 @@
import { ALL_APPS, AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { AppIcon, appSquarePaddingExpression, appSquareWidthExpression } from "@stackframe/stack-shared/dist/apps/apps-ui";
import { BarChart3, ClipboardList, CreditCard, KeyRound, Mail, Mails, Rocket, ShieldCheck, ShieldEllipsis, Sparkles, Triangle, Tv, UserCog, Users, Vault, Webhook } from "lucide-react";
import { BarChart3, ClipboardList, Code, CreditCard, KeyRound, Mail, Mails, Rocket, ShieldCheck, ShieldEllipsis, Sparkles, Triangle, Tv, UserCog, Users, Vault, Webhook } from "lucide-react";
import Link from "next/link";
import { cn } from "../../lib/cn";
@ -10,6 +10,7 @@ import { cn } from "../../lib/cn";
const APP_URL_OVERRIDES: Partial<Record<AppId, string>> = {
teams: '/docs/apps/orgs-and-teams',
rbac: '/docs/apps/permissions',
"tanstack-start": '/docs/guides/integrations/tanstack-start/overview',
};
// Icon mapping for docs (no Next.js Image dependencies)
@ -38,6 +39,7 @@ const APP_ICONS: Record<AppId, React.FunctionComponent<React.SVGProps<SVGSVGElem
</>
)),
vercel: Triangle,
"tanstack-start": Code,
onboarding: ClipboardList,
analytics: BarChart3,
};
@ -121,4 +123,3 @@ export function AppGrid({ appIds, className }: {
</div>
);
}

View File

@ -0,0 +1,3 @@
VITE_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02
VITE_STACK_PROJECT_ID=internal
VITE_STACK_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only

View File

@ -0,0 +1,4 @@
module.exports = {
extends: ["../../configs/eslint/defaults.js"],
ignorePatterns: ["/*", "!/src"],
};

View File

@ -0,0 +1,4 @@
.output
.tanstack
dist
node_modules

View File

@ -0,0 +1,40 @@
{
"name": "@stackframe/example-tanstack-start-demo",
"version": "2.8.86",
"repository": "https://github.com/stack-auth/stack-auth",
"description": "TanStack Start demo app for Stack Auth",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"clean": "rimraf .output && rimraf node_modules",
"dev": "vite dev --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}43",
"build": "vite build",
"start": "node .output/server/index.mjs",
"lint": "eslint --ext .ts,.tsx ."
},
"dependencies": {
"@stackframe/stack-shared": "workspace:*",
"@stackframe/stack-ui": "workspace:*",
"@stackframe/tanstack-start": "workspace:*",
"@tanstack/react-router": "^1.168.23",
"@tanstack/react-start": "^1.167.42",
"nitro": "^3.0.0",
"react": "19.2.1",
"react-dom": "19.2.1"
},
"devDependencies": {
"@types/node": "^22.13.0",
"@types/react": "19.2.1",
"@types/react-dom": "19.2.1",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"rimraf": "^5.0.10",
"tailwindcss": "^3.4.14",
"typescript": "5.9.3",
"vite": "^7.0.0",
"vite-tsconfig-paths": "^4.3.2"
},
"packageManager": "pnpm@10.23.0"
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,12 @@
import { StartClient } from "@tanstack/react-start/client";
import { StrictMode, startTransition } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<StartClient />
</StrictMode>,
);
});

View File

@ -0,0 +1,33 @@
import { Link } from "@tanstack/react-router";
import { UserButton } from "@stackframe/tanstack-start";
import { useEffect, useState } from "react";
export function Header() {
return (
<>
<header className="fixed left-0 right-0 top-0 z-50 border-b border-zinc-200 bg-white/95 px-4 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/95">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-4">
<nav className="flex items-center gap-4">
<Link to="/" className="font-semibold tracking-tight">
Stack TanStack Demo
</Link>
<Link to="/protected" className="text-sm text-zinc-600 hover:text-zinc-950 hover:transition-none dark:text-zinc-300 dark:hover:text-white">
Protected
</Link>
</nav>
<ClientMountedUserButton />
</div>
</header>
<div className="h-14" />
</>
);
}
function ClientMountedUserButton() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return isMounted ? <UserButton /> : <div className="h-9 w-9" />;
}

View File

@ -0,0 +1,104 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as ProtectedRouteImport } from './routes/protected'
import { Route as IndexRouteImport } from './routes/index'
import { Route as HandlerSplatRouteImport } from './routes/handler/$'
const ProtectedRoute = ProtectedRouteImport.update({
id: '/protected',
path: '/protected',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const HandlerSplatRoute = HandlerSplatRouteImport.update({
id: '/handler/$',
path: '/handler/$',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/protected': typeof ProtectedRoute
'/handler/$': typeof HandlerSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/protected': typeof ProtectedRoute
'/handler/$': typeof HandlerSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/protected': typeof ProtectedRoute
'/handler/$': typeof HandlerSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/protected' | '/handler/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/protected' | '/handler/$'
id: '__root__' | '/' | '/protected' | '/handler/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ProtectedRoute: typeof ProtectedRoute
HandlerSplatRoute: typeof HandlerSplatRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/protected': {
id: '/protected'
path: '/protected'
fullPath: '/protected'
preLoaderRoute: typeof ProtectedRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/handler/$': {
id: '/handler/$'
path: '/handler/$'
fullPath: '/handler/$'
preLoaderRoute: typeof HandlerSplatRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ProtectedRoute: ProtectedRoute,
HandlerSplatRoute: HandlerSplatRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

View File

@ -0,0 +1,18 @@
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
return createRouter({
routeTree,
scrollRestoration: true,
defaultNotFoundComponent: () => (
<main className="grid min-h-screen place-items-center bg-zinc-100 px-4 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50">
<div className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-8 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="mb-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">404</p>
<h1 className="text-2xl font-semibold tracking-tight">Page not found</h1>
<p className="mt-4 text-zinc-600 dark:text-zinc-300">This route is not part of the TanStack Start demo.</p>
</div>
</main>
),
});
}

View File

@ -0,0 +1,58 @@
/// <reference types="vite/client" />
import "../styles.css";
import { StackProvider, StackTheme } from "@stackframe/tanstack-start";
import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router";
import type { ReactNode } from "react";
import { Suspense, useMemo } from "react";
import { Header } from "~/components/header";
import { createStackApp } from "~/stack";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "Stack Auth TanStack Start Demo" },
{
name: "description",
content: "TanStack Start demo application using Stack Auth.",
},
],
}),
shellComponent: RootDocument,
component: RootComponent,
});
function RootDocument({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
function RootComponent() {
const stackApp = useMemo(() => createStackApp(), []);
return (
<StackProvider app={stackApp}>
<StackTheme>
<div className="min-h-screen bg-zinc-100 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50">
<Header />
<main className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-5xl px-4 py-8">
<Suspense fallback={null}>
<Outlet />
</Suspense>
</main>
</div>
</StackTheme>
</StackProvider>
);
}

View File

@ -0,0 +1,12 @@
import { StackHandler } from "@stackframe/tanstack-start";
import { createFileRoute, useLocation } from "@tanstack/react-router";
export const Route = createFileRoute("/handler/$")({
ssr: false,
component: HandlerPage,
});
function HandlerPage() {
const { pathname } = useLocation();
return <StackHandler fullPage location={pathname} />;
}

View File

@ -0,0 +1,76 @@
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { UserAvatar, useStackApp, useUser } from "@stackframe/tanstack-start";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: HomePage,
});
function HomePage() {
const user = useUser({ includeRestricted: true });
const app = useStackApp();
if (!user) {
return (
<section className="grid w-full place-items-center">
<div className="w-full max-w-xl rounded-lg border border-zinc-200 bg-white p-8 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="mb-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">TanStack Start alpha</p>
<h1 className="text-3xl font-semibold tracking-tight">Welcome to the Stack demo app.</h1>
<p className="mt-4 text-zinc-600 dark:text-zinc-300">
This example uses <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-sm dark:bg-zinc-800">@stackframe/tanstack-start</code> with file-based routes and Stack Auth handler pages.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<button className="rounded-md bg-zinc-950 px-4 py-2 text-sm font-medium text-white transition-colors hover:transition-none dark:bg-white dark:text-zinc-950" onClick={() => runAsynchronouslyWithAlert(app.redirectToSignIn())}>
Sign in
</button>
<button className="rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium transition-colors hover:bg-zinc-100 hover:transition-none dark:border-zinc-700 dark:hover:bg-zinc-800" onClick={() => runAsynchronouslyWithAlert(app.redirectToSignUp())}>
Sign up
</button>
</div>
</div>
</section>
);
}
return (
<section className="grid w-full place-items-center">
<div className="w-full max-w-2xl rounded-lg border border-zinc-200 bg-white p-8 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
<UserAvatar user={user} size={96} />
<div className="min-w-0">
<p className="text-sm text-zinc-500 dark:text-zinc-400">Signed in as</p>
<h1 className="truncate text-3xl font-semibold tracking-tight">{user.displayName ?? user.primaryEmail ?? user.id}</h1>
{user.isRestricted && (
<span className="mt-2 inline-flex rounded bg-amber-100 px-2 py-1 text-sm font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
Restricted
</span>
)}
</div>
</div>
<dl className="mt-8 grid gap-3 text-sm">
<div className="grid gap-1 sm:grid-cols-[8rem_1fr]">
<dt className="font-medium text-zinc-500 dark:text-zinc-400">User ID</dt>
<dd className="min-w-0 break-all font-mono">{user.id}</dd>
</div>
{user.primaryEmail && (
<div className="grid gap-1 sm:grid-cols-[8rem_1fr]">
<dt className="font-medium text-zinc-500 dark:text-zinc-400">Email</dt>
<dd>{user.primaryEmail}</dd>
</div>
)}
<div className="grid gap-1 sm:grid-cols-[8rem_1fr]">
<dt className="font-medium text-zinc-500 dark:text-zinc-400">Restricted</dt>
<dd>{user.isRestricted ? `Yes${user.restrictedReason ? ` (${user.restrictedReason.type})` : ""}` : "No"}</dd>
</div>
</dl>
<div className="mt-8 flex flex-wrap gap-3">
<button className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 hover:transition-none" onClick={() => runAsynchronouslyWithAlert(app.redirectToSignOut())}>
Sign out
</button>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,23 @@
import { useUser } from "@stackframe/tanstack-start";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/protected")({
ssr: false,
component: ProtectedPage,
});
function ProtectedPage() {
const user = useUser({ or: "redirect" });
return (
<section className="grid w-full place-items-center">
<div className="w-full max-w-xl rounded-lg border border-zinc-200 bg-white p-8 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="mb-2 text-sm font-medium text-green-600 dark:text-green-400">Protected route</p>
<h1 className="text-2xl font-semibold tracking-tight">You can see this because you are signed in.</h1>
<p className="mt-4 text-zinc-600 dark:text-zinc-300">
TanStack Start rendered this route with Stack Auth session state for <span className="font-medium text-zinc-950 dark:text-zinc-50">{user.displayName ?? user.primaryEmail ?? user.id}</span>.
</p>
</div>
</section>
);
}

View File

@ -0,0 +1,29 @@
import { StackClientApp } from "@stackframe/tanstack-start";
function getPortPrefix(): string {
return import.meta.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? "81";
}
function replaceStackPortPrefix(value: string): string {
return value.replace(/\$\{NEXT_PUBLIC_STACK_PORT_PREFIX:-81\}/g, getPortPrefix());
}
function getStackApiUrl(): string {
const configured = import.meta.env.VITE_STACK_API_URL as string | undefined;
return configured ? replaceStackPortPrefix(configured) : `http://localhost:${getPortPrefix()}02`;
}
export function createStackApp() {
return new StackClientApp({
projectId: import.meta.env.VITE_STACK_PROJECT_ID ?? "internal",
publishableClientKey: import.meta.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY ?? "this-publishable-client-key-is-for-local-development-only",
baseUrl: getStackApiUrl(),
tokenStore: "cookie",
redirectMethod: "window",
urls: {
afterSignIn: "/protected",
afterSignUp: "/protected",
afterSignOut: "/",
},
});
}

View File

@ -0,0 +1,24 @@
/* stylelint-disable scss/at-rule-no-unknown */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* stylelint-enable scss/at-rule-no-unknown */
:root {
color-scheme: light;
background: rgb(244 244 245);
}
html:has(head > [data-stack-theme="dark"]) {
color-scheme: dark;
background: rgb(9 9 11);
}
body {
margin: 0;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}

View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["selector", 'html:has(head > [data-stack-theme="dark"])'],
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/stack-ui/src/**/*.{ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

View File

@ -0,0 +1,94 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig, type Plugin } from "vite";
import { nitro } from "nitro/vite";
import tsConfigPaths from "vite-tsconfig-paths";
const stackAuthRootPath = fileURLToPath(new URL("../..", import.meta.url));
function watchNodeModules(modules: string[]): Plugin {
return {
name: "watch-node-modules",
config() {
return {
server: {
watch: {
ignored: modules.map((moduleName) => `!**/node_modules/${moduleName}/**`),
},
},
};
},
};
}
function waitForWorkspacePackages(packages: string[]): Plugin {
const packageDistEntries = packages.map((pkg) => ({
name: pkg,
entry: path.resolve(__dirname, "node_modules", pkg, "dist", "esm", "index.js"),
}));
async function waitForFile(filePath: string, timeoutMs = 60_000): Promise<void> {
if (fs.existsSync(filePath)) return;
const start = performance.now();
return await new Promise((resolve, reject) => {
const interval = setInterval(() => {
if (fs.existsSync(filePath)) {
clearInterval(interval);
resolve();
} else if (performance.now() - start > timeoutMs) {
clearInterval(interval);
reject(new Error(`Timed out waiting for ${filePath} to exist`));
}
}, 500);
});
}
return {
name: "wait-for-workspace-packages",
enforce: "pre",
async buildStart() {
const missing = packageDistEntries.filter((pkg) => !fs.existsSync(pkg.entry));
if (missing.length === 0) return;
console.log(`Waiting for workspace packages to build: ${missing.map((pkg) => pkg.name).join(", ")}`);
await Promise.all(missing.map((pkg) => waitForFile(pkg.entry)));
console.log("All workspace packages are ready.");
},
};
}
export default defineConfig(({ mode }) => {
const isVitest = mode === "test" || process.env.VITEST === "true";
return {
server: {
port: Number(`${process.env.NEXT_PUBLIC_STACK_PORT_PREFIX || "81"}43`),
fs: {
allow: [stackAuthRootPath],
},
},
resolve: {
dedupe: ["react", "react-dom"],
},
ssr: {
noExternal: [/^@stackframe\//, /^@radix-ui\//],
},
optimizeDeps: {
include: ["@stackframe/stack-shared", "@stackframe/stack-shared/config"],
},
plugins: [
...(isVitest ? [] : [
waitForWorkspacePackages(["@stackframe/tanstack-start", "@stackframe/stack-shared", "@stackframe/stack-ui"]),
watchNodeModules(["@stackframe/tanstack-start", "@stackframe/stack-shared", "@stackframe/stack-ui"]),
]),
tsConfigPaths(),
...(isVitest ? [] : [
tanstackStart(),
nitro(),
]),
viteReact(),
],
};
});

View File

@ -51,8 +51,8 @@
"db:migrate": "pnpm pre && pnpm run --filter=@stackframe/backend db:migrate",
"fern": "pnpm pre && pnpm run --filter=@stackframe/docs fern",
"dev:full": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-setup-prompt-docs:watch\" \"turbo run dev --concurrency 99999\"",
"dev": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-openapi-docs:watch\" \"pnpm run generate-setup-prompt-docs:watch\" \"turbo run dev --concurrency 99999 --filter=./apps/* --filter=@stackframe/docs-mintlify --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo \"",
"dev:tui": "pnpm pre && (trap 'kill 0' EXIT; pnpm run generate-sdks:watch & pnpm run generate-openapi-docs:watch & pnpm run generate-setup-prompt-docs:watch & turbo run dev --ui tui --concurrency 99999 --filter=./apps/* --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo)",
"dev": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-openapi-docs:watch\" \"pnpm run generate-setup-prompt-docs:watch\" \"turbo run dev --concurrency 99999 --filter=./apps/* --filter=@stackframe/docs-mintlify --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo --filter=./examples/tanstack-start-demo \"",
"dev:tui": "pnpm pre && (trap 'kill 0' EXIT; pnpm run generate-sdks:watch & pnpm run generate-openapi-docs:watch & pnpm run generate-setup-prompt-docs:watch & turbo run dev --ui tui --concurrency 99999 --filter=./apps/* --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo --filter=./examples/tanstack-start-demo)",
"dev:inspect": "pnpm pre && STACK_BACKEND_DEV_EXTRA_ARGS=\"--inspect\" pnpm run dev",
"dev:profile": "pnpm pre && STACK_BACKEND_DEV_EXTRA_ARGS=\"--experimental-cpu-prof\" pnpm run dev",
"dev:basic": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"turbo run dev --concurrency 99999 --filter=@stackframe/backend --filter=@stackframe/mcp --filter=@stackframe/dashboard --filter=@stackframe/mock-oauth-server\"",

View File

@ -85,4 +85,4 @@
"tsdown": "^0.20.3",
"convex": "^1.27.0"
}
}
}

View File

@ -114,4 +114,4 @@
"tsdown": "^0.20.3",
"convex": "^1.27.0"
}
}
}

View File

@ -150,6 +150,12 @@ export const ALL_APPS = {
tags: ["integration", "developers"],
stage: "stable",
},
"tanstack-start": {
displayName: "TanStack Start",
subtitle: "Use Stack Auth in TanStack Start apps",
tags: ["integration", "developers"],
stage: "alpha",
},
"analytics": {
displayName: "Analytics",
subtitle: "View and explore analytics data",

View File

@ -122,4 +122,4 @@
"tsdown": "^0.20.3",
"convex": "^1.27.0"
}
}
}

View File

@ -0,0 +1,132 @@
{
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY, INSTEAD EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
"name": "@stackframe/tanstack-start",
"version": "2.8.88",
"repository": "https://github.com/hexclave/stack-auth",
"sideEffects": false,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": {
"default": "./dist/esm/index.js"
},
"require": {
"default": "./dist/index.js"
}
},
"./tanstack-start-server-context": {
"types": "./dist/tanstack-start-server-context.combined.d.ts",
"import": {
"browser": "./dist/esm/tanstack-start-server-context.default.js",
"default": "./dist/esm/tanstack-start-server-context.server.js"
},
"require": {
"browser": "./dist/tanstack-start-server-context.default.js",
"default": "./dist/tanstack-start-server-context.server.js"
}
},
"./convex.config": {
"types": "./dist/integrations/convex/component/convex.config.d.ts",
"import": {
"default": "./dist/esm/integrations/convex/component/convex.config.js"
},
"require": {
"default": "./dist/integrations/convex/component/convex.config.js"
}
},
"./convex-auth.config": {
"types": "./dist/integrations/convex.d.ts",
"import": {
"default": "./dist/esm/integrations/convex.js"
},
"require": {
"default": "./dist/integrations/convex.js"
}
}
},
"homepage": "https://stack-auth.com",
"scripts": {
"typecheck": "tsc --noEmit",
"clean": "rimraf dist && rimraf node_modules",
"lint": "eslint --ext .tsx,.ts .",
"build": "rimraf dist && pnpm run css && tsdown",
"dev": "concurrently -n \"build,codegen\" -k \"tsdown --watch\" \"pnpm run codegen:watch\"",
"codegen": "pnpm run css",
"codegen:watch": "pnpm run css:watch",
"css": "pnpm run css-tw && pnpm run css-sc",
"css:watch": "concurrently -n \"tw,sc\" -k \"pnpm run css-tw:watch\" \"pnpm run css-sc:watch\"",
"css-tw:watch": "tailwindcss -i ./src/global.css -o ./src/generated/tailwind.css --watch",
"css-tw": "tailwindcss -i ./src/global.css -o ./src/generated/tailwind.css",
"css-sc": "tsx ./scripts/process-css.ts ./src/generated/tailwind.css ./src/generated/global-css.ts",
"css-sc:watch": "chokidar --silent './src/generated/tailwind.css' -c 'pnpm run css-sc' --throttle 2000"
},
"files": [
"README.md",
"dist",
"CHANGELOG.md",
"LICENSE"
],
"dependencies": {
"@ai-sdk/react": "^3.0.72",
"ai": "^6.0.0",
"@hookform/resolvers": "^5.2.2",
"@stripe/react-stripe-js": "^3.8.1",
"@stripe/stripe-js": "^7.7.0",
"@simplewebauthn/browser": "^13.2.2",
"@stackframe/stack-shared": "workspace:*",
"@stackframe/stack-ui": "workspace:*",
"@tanstack/react-table": "^8.21.3",
"browser-image-compression": "^2.0.2",
"color": "^5.0.3",
"cookie": "^1.1.1",
"jose": "^6.1.3",
"js-cookie": "^3.0.5",
"lucide-react": "^0.378.0",
"oauth4webapi": "^3.8.3",
"@oslojs/otp": "^1.1.0",
"qrcode": "^1.5.4",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.70.0",
"tailwindcss-animate": "^1.0.7",
"rrweb": "^1.1.3",
"tsx": "^4.21.0",
"yup": "^1.7.1"
},
"peerDependencies": {
"@types/react": ">=18.3.0",
"@tanstack/react-router": ">=1.100.0",
"@tanstack/react-start": ">=1.100.0",
"react": ">=18.3.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
},
"devDependencies": {
"@quetzallabs/i18n": "^0.1.19",
"@types/color": "^3.0.6",
"@types/cookie": "^0.6.0",
"@types/js-cookie": "^3.0.6",
"@types/qrcode": "^1.5.5",
"@types/react-avatar-editor": "^13.0.3",
"autoprefixer": "^10.4.17",
"chokidar-cli": "^3.0.0",
"esbuild": "^0.20.2",
"i18next": "^23.14.0",
"i18next-parser": "^9.0.2",
"@tanstack/react-router": "^1.167.4",
"@tanstack/react-start": "^1.166.15",
"postcss": "^8.4.38",
"postcss-nested": "^6.0.1",
"react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react-dom": "^19.0.0",
"rimraf": "^6.1.2",
"tailwindcss": "^3.4.4",
"tsdown": "^0.20.3",
"convex": "^1.27.0"
}
}

View File

@ -5,6 +5,8 @@
"name": "@stackframe/js",
"//": "ELSE_IF_PLATFORM next",
"name": "@stackframe/stack",
"//": "ELSE_IF_PLATFORM tanstack-start",
"name": "@stackframe/tanstack-start",
"//": "ELSE_IF_PLATFORM react",
"name": "@stackframe/react",
"//": "END_PLATFORM",
@ -26,6 +28,19 @@
"default": "./dist/index.js"
}
},
"//": "IF_PLATFORM tanstack-start",
"./tanstack-start-server-context": {
"types": "./dist/tanstack-start-server-context.combined.d.ts",
"import": {
"browser": "./dist/esm/tanstack-start-server-context.default.js",
"default": "./dist/esm/tanstack-start-server-context.server.js"
},
"require": {
"browser": "./dist/tanstack-start-server-context.default.js",
"default": "./dist/tanstack-start-server-context.server.js"
}
},
"//": "END_PLATFORM",
"./convex.config": {
"types": "./dist/integrations/convex/component/convex.config.d.ts",
"import": {
@ -131,6 +146,10 @@
"react-dom": ">=18.3.0",
"next": ">=14.1 || >=15.0.0-canary.0 || >=15.0.0-rc.0",
"//": "END_PLATFORM",
"//": "IF_PLATFORM tanstack-start",
"@tanstack/react-router": ">=1.100.0",
"@tanstack/react-start": ">=1.100.0",
"//": "END_PLATFORM",
"react": ">=18.3.0"
},
"//": "END_PLATFORM",
@ -160,6 +179,10 @@
"i18next-parser": "^9.0.2",
"//": "NEXT_LINE_PLATFORM next",
"next": "^14.2.35",
"//": "NEXT_LINE_PLATFORM template tanstack-start",
"@tanstack/react-router": "^1.167.4",
"//": "NEXT_LINE_PLATFORM template tanstack-start",
"@tanstack/react-start": "^1.166.15",
"postcss": "^8.4.38",
"postcss-nested": "^6.0.1",
"react": "^19.0.0",

View File

@ -17,6 +17,17 @@
"default": "./dist/index.js"
}
},
"./tanstack-start-server-context": {
"types": "./dist/tanstack-start-server-context.combined.d.ts",
"import": {
"browser": "./dist/esm/tanstack-start-server-context.default.js",
"default": "./dist/esm/tanstack-start-server-context.server.js"
},
"require": {
"browser": "./dist/tanstack-start-server-context.default.js",
"default": "./dist/tanstack-start-server-context.server.js"
}
},
"./convex.config": {
"types": "./dist/integrations/convex/component/convex.config.d.ts",
"import": {
@ -94,6 +105,8 @@
"@types/react-dom": ">=18.3.0",
"react-dom": ">=18.3.0",
"next": ">=14.1 || >=15.0.0-canary.0 || >=15.0.0-rc.0",
"@tanstack/react-router": ">=1.100.0",
"@tanstack/react-start": ">=1.100.0",
"react": ">=18.3.0"
},
"peerDependenciesMeta": {
@ -117,6 +130,8 @@
"i18next": "^23.14.0",
"i18next-parser": "^9.0.2",
"next": "^14.2.35",
"@tanstack/react-router": "^1.167.4",
"@tanstack/react-start": "^1.166.15",
"postcss": "^8.4.38",
"postcss-nested": "^6.0.1",
"react": "^19.0.0",
@ -127,4 +142,4 @@
"tsdown": "^0.20.3",
"convex": "^1.27.0"
}
}
}

View File

@ -236,8 +236,8 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps>
const navigate = stackApp.useNavigate();
const navigateRef = useRef(navigate);
navigateRef.current = navigate;
const currentLocation = props.location ?? window.location.pathname;
const searchParamsSource = new URLSearchParams(window.location.search);
const currentLocation = props.location ?? (typeof window === "undefined" ? new URL(stackApp.urls.handler, placeholderOrigin).pathname : window.location.pathname);
const searchParamsSource = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
const redirectTargets: (string | undefined)[] = [];
END_PLATFORM */

View File

@ -1,6 +1,7 @@
import { cookies as rscCookies, headers as rscHeaders } from '@stackframe/stack-sc/force-react-server'; // THIS_LINE_PLATFORM next
import { isBrowserLike } from '@stackframe/stack-shared/dist/utils/env';
import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors';
import * as tanstackStartServerContext from "@stackframe/tanstack-start/tanstack-start-server-context"; // THIS_LINE_PLATFORM tanstack-start
import Cookies from "js-cookie";
import { calculatePKCECodeChallenge, generateRandomCodeVerifier, generateRandomState } from "oauth4webapi";
@ -67,6 +68,49 @@ import { calculatePKCECodeChallenge, generateRandomCodeVerifier, generateRandomS
type SetCookieOptions = { maxAge: number | "session", noOpIfServerComponent?: boolean, domain?: string, secure?: boolean };
type DeleteCookieOptions = { noOpIfServerComponent?: boolean, domain?: string };
// IF_PLATFORM tanstack-start
let tanStackStartCookieHelperPromise: Promise<CookieHelper> | null = null;
function getTanStackStartServerContext() {
const {
deleteCookie,
getCookie,
getCookies,
getRequestHeader,
setCookie,
} = tanstackStartServerContext;
if (
deleteCookie == null
|| getCookie == null
|| getCookies == null
|| getRequestHeader == null
|| setCookie == null
) {
throw new StackAssertionError("TanStack Start server context is only available during server rendering");
}
return {
deleteCookie,
getCookie,
getCookies,
getRequestHeader,
setCookie,
};
}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface ImportMetaEnv {
SSR: boolean,
}
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface ImportMeta {
readonly env: ImportMetaEnv,
}
}
// END_PLATFORM
function ensureClient() {
if (!isBrowserLike()) {
throw new Error("cookieClient functions can only be called in a browser environment, yet window is undefined");
@ -95,6 +139,16 @@ export async function createPlaceholderCookieHelper(): Promise<CookieHelper> {
};
}
function requiresSecureAttribute(name: string): boolean {
return name.startsWith("__Host-");
}
function validateCookieOptions(name: string, options: DeleteCookieOptions | SetCookieOptions) {
if (requiresSecureAttribute(name) && options.domain !== undefined) {
throw new StackAssertionError("__Host- cookies must not specify a Domain attribute");
}
}
export async function createCookieHelper(): Promise<CookieHelper> {
if (isBrowserLike()) {
return createBrowserCookieHelper();
@ -104,12 +158,90 @@ export async function createCookieHelper(): Promise<CookieHelper> {
await rscCookies(),
await rscHeaders(),
);
// ELSE_IF_PLATFORM tanstack-start
if (import.meta.env.SSR) {
const cookieHelperPromise = tanStackStartCookieHelperPromise
?? Promise.resolve(createTanStackStartCookieHelper(getTanStackStartServerContext()));
tanStackStartCookieHelperPromise = cookieHelperPromise;
return await cookieHelperPromise;
}
return await createPlaceholderCookieHelper();
// ELSE_PLATFORM
return await createPlaceholderCookieHelper();
// END_PLATFORM
}
}
export function createCookieHelperSync(): CookieHelper {
if (isBrowserLike()) {
return createBrowserCookieHelper();
}
function throwError(): never {
throw new StackAssertionError("Synchronous server cookie helpers are not available on this platform");
}
return {
get: throwError,
getAll: throwError,
set: throwError,
setOrDelete: throwError,
delete: throwError,
};
}
// IF_PLATFORM tanstack-start
function determineSecureFromTanStackStartContext(api: ReturnType<typeof getTanStackStartServerContext>): boolean {
return api.getRequestHeader("x-forwarded-proto") === "https"
|| (api.getCookie("stack-is-https") !== undefined);
}
function refreshTanStackStartIsHttpsCookie(api: ReturnType<typeof getTanStackStartServerContext>) {
api.setCookie("stack-is-https", "true", {
secure: true,
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
path: "/",
});
}
function createTanStackStartCookieHelper(api: ReturnType<typeof getTanStackStartServerContext>): CookieHelper {
const helper: CookieHelper = {
get: (name: string) => {
const all = helper.getAll();
return all[name] ?? null;
},
getAll: () => {
// set a helper cookie, see comment in `NextCookieHelper.set` below
refreshTanStackStartIsHttpsCookie(api);
return api.getCookies();
},
set: (name: string, value: string, options: SetCookieOptions) => {
validateCookieOptions(name, options);
api.setCookie(name, value, {
secure: requiresSecureAttribute(name) || (options.secure ?? determineSecureFromTanStackStartContext(api)),
maxAge: options.maxAge === "session" ? undefined : options.maxAge,
domain: options.domain,
sameSite: "lax",
path: "/",
});
},
setOrDelete: (name, value, options) => {
if (value === null) helper.delete(name, options);
else helper.set(name, value, options);
},
delete: (name: string, options: DeleteCookieOptions) => {
validateCookieOptions(name, options);
const secure = requiresSecureAttribute(name) || determineSecureFromTanStackStartContext(api);
api.deleteCookie(name, {
secure,
domain: options.domain,
path: "/",
});
},
};
return helper;
}
// END_PLATFORM
export function createBrowserCookieHelper(): CookieHelper {
return {
get: getCookieClient,
@ -166,6 +298,7 @@ function createNextCookieHelper(
}, {} as Record<string, string>);
},
set: (name: string, value: string, options: SetCookieOptions) => {
validateCookieOptions(name, options);
// Whenever the client is on HTTPS, we want to set the Secure flag on the cookie.
//
// This is not easy to find out on a Next.js server, so see the algorithm at the top of this file.
@ -177,10 +310,11 @@ function createNextCookieHelper(
try {
rscCookiesAwaited.set(name, value, {
secure: isSecureCookie,
secure: requiresSecureAttribute(name) || isSecureCookie,
maxAge: options.maxAge === "session" ? undefined : options.maxAge,
domain: options.domain,
sameSite: "lax",
path: "/",
});
} catch (e) {
handleCookieError(e, options);
@ -195,10 +329,11 @@ function createNextCookieHelper(
},
delete(name: string, options: DeleteCookieOptions) {
try {
validateCookieOptions(name, options);
if (options.domain !== undefined) {
rscCookiesAwaited.delete({ name, domain: options.domain });
rscCookiesAwaited.delete({ name, domain: options.domain, path: "/" });
} else {
rscCookiesAwaited.delete(name);
rscCookiesAwaited.delete({ name, path: "/" });
}
} catch (e) {
handleCookieError(e, options);
@ -232,6 +367,10 @@ export async function isSecure(): Promise<boolean> {
}
// IF_PLATFORM next
return determineSecureFromServerContext(await rscCookies(), await rscHeaders());
// ELSE_IF_PLATFORM tanstack-start
if (import.meta.env.SSR) {
return determineSecureFromTanStackStartContext(getTanStackStartServerContext());
}
// END_PLATFORM
return false;
}
@ -299,12 +438,14 @@ function _internalShouldSetPartitionedClient() {
}
function setCookieClientInternal(name: string, value: string, options: SetCookieOptions) {
const secure = options.secure ?? determineSecureFromClientContext();
validateCookieOptions(name, options);
const secure = requiresSecureAttribute(name) || (options.secure ?? determineSecureFromClientContext());
const partitioned = shouldSetPartitionedClient();
Cookies.set(name, value, {
expires: options.maxAge === "session" ? undefined : new Date(Date.now() + (options.maxAge) * 1000),
domain: options.domain,
secure,
path: "/",
sameSite: "Lax",
...(partitioned ? {
partitioned,
@ -314,11 +455,12 @@ function setCookieClientInternal(name: string, value: string, options: SetCookie
}
function deleteCookieClientInternal(name: string, options: DeleteCookieOptions) {
validateCookieOptions(name, options);
for (const partitioned of [true, false]) {
if (options.domain !== undefined) {
Cookies.remove(name, { domain: options.domain, secure: determineSecureFromClientContext(), partitioned });
Cookies.remove(name, { domain: options.domain, secure: determineSecureFromClientContext(), partitioned, path: "/" });
}
Cookies.remove(name, { secure: determineSecureFromClientContext(), partitioned });
Cookies.remove(name, { secure: requiresSecureAttribute(name) || determineSecureFromClientContext(), partitioned, path: "/" });
}
}

View File

@ -36,6 +36,8 @@ import { BotChallengeExecutionFailedError, BotChallengeUserCancelledError, withB
import type { TurnstileAction } from "@stackframe/stack-shared/dist/utils/turnstile";
import { isRelative } from "@stackframe/stack-shared/dist/utils/urls";
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import * as tanstackStartServerContext from "@stackframe/tanstack-start/tanstack-start-server-context"; // THIS_LINE_PLATFORM tanstack-start
import * as TanStackRouter from "@tanstack/react-router"; // THIS_LINE_PLATFORM tanstack-start
import * as cookie from "cookie";
import * as NextNavigationUnscrambled from "next/navigation"; // import the entire module to get around some static compiler warnings emitted by Next.js in some cases | THIS_LINE_PLATFORM next
import React, { useCallback, useMemo } from "react"; // THIS_LINE_PLATFORM react-like
@ -152,6 +154,26 @@ function getHeaderValueFromRequestLikeHeaders(headers: RequestLike["headers"], n
return null;
}
// IF_PLATFORM tanstack-start
function getTanStackStartRequestHeader(name: string): string | null {
const { getRequestHeader } = tanstackStartServerContext;
if (getRequestHeader == null) {
throw new StackAssertionError("TanStack Start request headers are only available during server rendering");
}
return getRequestHeader(name) ?? null;
}
// END_PLATFORM
async function getServerRequestHost(): Promise<string | null> {
// IF_PLATFORM next
return (await sc.headers?.())?.get("host") ?? null;
// ELSE_IF_PLATFORM tanstack-start
return getTanStackStartRequestHeader("host");
// ELSE_PLATFORM
return null;
// END_PLATFORM
}
type StackClientAppImplConstructorOptionsResolved<HasTokenStore extends boolean, ProjectId extends string> = StackClientAppConstructorOptions<HasTokenStore, ProjectId> & { inheritsFrom?: undefined };
export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, ProjectId extends string = string> implements StackClientApp<HasTokenStore, ProjectId> {
@ -608,6 +630,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
this._tokenStoreInit = resolvedOptions.tokenStore;
this._redirectMethod = resolvedOptions.redirectMethod || (isBrowserLike() ? "window" : "none");
this._redirectMethod = resolvedOptions.redirectMethod || "nextjs"; // THIS_LINE_PLATFORM next
this._redirectMethod = resolvedOptions.redirectMethod || "tanstack-start"; // THIS_LINE_PLATFORM tanstack-start
this._urlOptions = resolvedOptions.urls ?? {};
this._oauthScopesOnSignIn = resolvedOptions.oauthScopesOnSignIn ?? {};
if (isBrowserLike() && (resolvedOptions.tokenStore === "cookie" || resolvedOptions.tokenStore === "nextjs-cookie")) {
@ -888,12 +911,9 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
let hostname;
if (isBrowserLike()) {
hostname = window.location.hostname;
} else {
hostname = await getServerRequestHost();
}
// IF_PLATFORM next
else {
hostname = (await sc.headers?.())?.get("host");
}
// END_PLATFORM
if (!hostname) {
console.warn("No hostname found when queueing custom refresh cookie update");
return;
@ -997,6 +1017,11 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
switch (tokenStoreInit) {
case "cookie": {
// IF_PLATFORM tanstack-start
if (!isBrowserLike()) {
return this._getOrCreateTokenStore(cookieHelper, "nextjs-cookie");
}
// END_PLATFORM
return this._getBrowserCookieTokenStore();
}
case "nextjs-cookie": {
@ -1105,6 +1130,11 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
// IF_PLATFORM react-like
protected _useTokenStore(overrideTokenStoreInit?: TokenStoreInit): Store<TokenObject> {
// IF_PLATFORM tanstack-start
if (!isBrowserLike()) {
return this._getOrCreateTokenStore(use(createCookieHelper()), overrideTokenStoreInit);
}
// END_PLATFORM
suspendIfSsr();
const cookieHelper = createBrowserCookieHelper();
const tokenStore = this._getOrCreateTokenStore(cookieHelper, overrideTokenStoreInit);
@ -2520,6 +2550,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
} else if (isReactServer && this._redirectMethod === "nextjs") {
NextNavigation.redirect(options.url.toString(), options.replace ? NextNavigation.RedirectType.replace : NextNavigation.RedirectType.push);
// END_PLATFORM
// IF_PLATFORM tanstack-start
} else if (this._redirectMethod === "tanstack-start" && !isBrowserLike()) {
throw TanStackRouter.redirect({ href: options.url.toString(), replace: options.replace });
// END_PLATFORM
} else if (typeof this._redirectMethod === "object" && this._redirectMethod.navigate) {
this._redirectMethod.navigate(options.url.toString());
} else {
@ -2544,6 +2578,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
const router = NextNavigation.useRouter();
return (to: string) => router.push(to);
// END_PLATFORM
// IF_PLATFORM tanstack-start
} else if (this._redirectMethod === "tanstack-start") {
return (to: string) => window.location.assign(to);
// END_PLATFORM
} else {
return (to: string) => { };
}
@ -2589,6 +2627,20 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
await this._redirectIfTrusted(plan.url, options);
}
protected _redirectToHandlerDuringRender(handlerName: keyof HandlerUrls, options?: RedirectToOptions): boolean {
// IF_PLATFORM tanstack-start
if (this._redirectMethod === "tanstack-start" && !isBrowserLike()) {
const rawUrls = getUrls(this._urlOptions, { projectId: this.projectId });
const rawHandlerUrl = rawUrls[handlerName];
if (!rawHandlerUrl) {
throw new Error(`No URL for handler name ${handlerName}`);
}
throw TanStackRouter.redirect({ href: rawHandlerUrl, replace: options?.replace });
}
// END_PLATFORM
return false;
}
async redirectToSignIn(options?: RedirectToOptions) { return await this._redirectToHandler("signIn", options); }
async redirectToSignUp(options?: RedirectToOptions) { return await this._redirectToHandler("signUp", options); }
async redirectToSignOut(options?: RedirectToOptions) { return await this._redirectToHandler("signOut", options); }
@ -2742,9 +2794,13 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
switch (options?.or) {
case 'redirect': {
if (!crud?.is_anonymous && crud?.is_restricted) {
runAsynchronously(this.redirectToOnboarding({ replace: true }));
if (!this._redirectToHandlerDuringRender("onboarding", { replace: true })) {
runAsynchronously(this.redirectToOnboarding({ replace: true }));
}
} else {
runAsynchronously(this.redirectToSignIn({ replace: true }));
if (!this._redirectToHandlerDuringRender("signIn", { replace: true })) {
runAsynchronously(this.redirectToSignIn({ replace: true }));
}
}
suspend();
throw new StackAssertionError("suspend should never return");

View File

@ -184,7 +184,17 @@ export function createEmptyTokenStore() {
const cachePromiseByHookId = new Map<string, ReactPromise<Result<unknown>>>();
export function useAsyncCache<D extends any[], T>(cache: AsyncCache<D, Result<T>>, dependencies: D, caller: string): T {
// we explicitly don't want to run this hook in SSR
// IF_PLATFORM tanstack-start
if (!isBrowserLike()) {
const result = use(cache.getOrWait(dependencies, "read-write"));
if (result.status === "error") {
throw result.error;
}
return result.data;
}
// ELSE_PLATFORM
suspendIfSsr(caller);
// END_PLATFORM
// on the dashboard, we do some perf monitoring for pre-fetching which should hook right in here
const asyncCacheHooks: any[] = getGlobal("use-async-cache-execution-hooks") ?? [];
@ -220,7 +230,7 @@ export function useAsyncCache<D extends any[], T>(cache: AsyncCache<D, Result<T>
const promise = React.useSyncExternalStore(
subscribe,
getSnapshot,
() => throwErr(new Error("getServerSnapshot should never be called in useAsyncCache because we restrict to CSR earlier"))
getSnapshot,
);
const result = use(promise);

View File

@ -30,6 +30,7 @@ export type EmailConfig = {
export type RedirectMethod = "window"
| "nextjs" // THIS_LINE_PLATFORM next
| "tanstack-start" // THIS_LINE_PLATFORM tanstack-start
| "none"
| {
useNavigate: () => (to: string) => void,

View File

@ -31,6 +31,35 @@ function NextStackProvider({
</StackProviderClient>
);
}
// ELSE_IF_PLATFORM tanstack-start
function TanStackStartStackProvider({
children,
app,
lang,
translationOverrides,
}: {
lang?: React.ComponentProps<typeof TranslationProvider>['lang'],
/**
* A mapping of English translations to translated equivalents.
*
* These will take priority over the translations from the language specified in the `lang` property. Note that the
* keys are case-sensitive.
*/
translationOverrides?: Record<string, string>,
children: React.ReactNode,
// list all three types of apps even though server and admin are subclasses of client so it's clear that you can pass any
app: StackClientApp<true>,
}) {
return (
<StackProviderClient app={app} serialized={false}>
<TranslationProvider lang={lang} translationOverrides={translationOverrides}>
<Suspense fallback={null}>
{children}
</Suspense>
</TranslationProvider>
</StackProviderClient>
);
}
// ELSE_PLATFORM
function ReactStackProvider({
children,
@ -63,6 +92,8 @@ function ReactStackProvider({
// IF_PLATFORM next
export default NextStackProvider;
/* ELSE_PLATFORM
/* ELSE_IF_PLATFORM tanstack-start
export default TanStackStartStackProvider;
ELSE_PLATFORM
export default ReactStackProvider;
END_PLATFORM */

View File

@ -0,0 +1,8 @@
import * as browserContext from "./tanstack-start-server-context.default";
import * as serverContext from "./tanstack-start-server-context.server";
export declare const getCookie: typeof serverContext.getCookie | typeof browserContext.getCookie;
export declare const getCookies: typeof serverContext.getCookies | typeof browserContext.getCookies;
export declare const setCookie: typeof serverContext.setCookie | typeof browserContext.setCookie;
export declare const deleteCookie: typeof serverContext.deleteCookie | typeof browserContext.deleteCookie;
export declare const getRequestHeader: typeof serverContext.getRequestHeader | typeof browserContext.getRequestHeader;

View File

@ -0,0 +1,9 @@
declare module "@stackframe/tanstack-start/tanstack-start-server-context" {
type TanStackStartServerContext = typeof import("@tanstack/react-start/server");
export const deleteCookie: TanStackStartServerContext["deleteCookie"] | undefined;
export const getCookie: TanStackStartServerContext["getCookie"] | undefined;
export const getCookies: TanStackStartServerContext["getCookies"] | undefined;
export const getRequestHeader: TanStackStartServerContext["getRequestHeader"] | undefined;
export const setCookie: TanStackStartServerContext["setCookie"] | undefined;
}

View File

@ -0,0 +1,5 @@
export const getCookie = undefined;
export const getCookies = undefined;
export const setCookie = undefined;
export const deleteCookie = undefined;
export const getRequestHeader = undefined;

View File

@ -0,0 +1,7 @@
export {
deleteCookie,
getCookie,
getCookies,
getRequestHeader,
setCookie,
} from "@tanstack/react-start/server";

View File

@ -3,6 +3,8 @@ import { fileURLToPath } from 'node:url'
import { defineConfig, mergeConfig } from 'vitest/config'
import sharedConfig from '../../vitest.shared'
const tanstackStartServerContextStub = fileURLToPath(new URL('./src/tanstack-start-server-context.default.ts', import.meta.url)) // THIS_LINE_PLATFORM template
const SOURCE_FILE_PATTERN = /\.(jsx?|tsx?)$/;
const CLIENT_VERSION_SENTINEL = "STACK_COMPILE_TIME_CLIENT_PACKAGE_VERSION_SENTINEL";
const ENFORCE_PRE: "pre" = "pre";
@ -45,6 +47,11 @@ const replaceCompileTimeClientVersion = () => {
export default mergeConfig(
sharedConfig,
defineConfig({
resolve: {
alias: {
"@stackframe/tanstack-start/tanstack-start-server-context": tanstackStartServerContextStub, // THIS_LINE_PLATFORM template
},
},
plugins: [replaceCompileTimeClientVersion()],
}),
)

View File

@ -54,7 +54,7 @@ function generateFromTemplate(options: {
// If the resulting file is package.json, add a comment field to the JSON.
if (path.basename(relativePath) === "package.json") {
const jsonObj = JSON.parse(newContent);
newContent = JSON.stringify({ "//": COMMENT_LINE, ...jsonObj }, null, 2);
newContent = JSON.stringify({ "//": COMMENT_LINE, ...jsonObj }, null, 2) + "\n";
}
return newContent;
@ -109,7 +109,7 @@ function processPackageJson(path: string, content: string) {
} catch (error) {
throw new Error(`Failed to parse package.json at ${path}`, { cause: error });
}
return JSON.stringify({ "//": `${COMMENT_LINE} (FOR package.json FILES, PLEASE EDIT package-template.json)`, ...jsonObj }, null, 2);
return JSON.stringify({ "//": `${COMMENT_LINE} (FOR package.json FILES, PLEASE EDIT package-template.json)`, ...jsonObj }, null, 2) + "\n";
}
function baseEditFn(options: {
@ -131,6 +131,14 @@ function baseEditFn(options: {
withGeneratorLock(async () => {
const baseDir = path.resolve(__dirname, "..", "packages");
const srcDir = path.resolve(baseDir, "template");
const tanstackStartOnlyTemplateFiles = new Set([
"src/tanstack-start-server-context.combined.ts",
"src/tanstack-start-server-context.default.ts",
"src/tanstack-start-server-context.server.ts",
]);
const templateOnlyFiles = new Set([
"src/tanstack-start-server-context.d.ts",
]);
// Copy package-template.json to package.json in the template,
// applying macros and adding a comment field.
@ -168,7 +176,9 @@ withGeneratorLock(async () => {
"src/global.d.ts",
];
if (ignores.some((ignorePath) => relativePath.startsWith(ignorePath)) || relativePath.endsWith(".tsx")) {
if (tanstackStartOnlyTemplateFiles.has(relativePath) || templateOnlyFiles.has(relativePath)) {
return false;
} else if (ignores.some((ignorePath) => relativePath.startsWith(ignorePath)) || relativePath.endsWith(".tsx")) {
return false;
} else {
return true;
@ -182,6 +192,7 @@ withGeneratorLock(async () => {
editFn: (relativePath, content) => {
return baseEditFn({ relativePath, content, platforms: PLATFORMS["next"] });
},
filterFn: (relativePath) => !tanstackStartOnlyTemplateFiles.has(relativePath),
});
generateFromTemplate({
@ -190,6 +201,16 @@ withGeneratorLock(async () => {
editFn: (relativePath, content) => {
return baseEditFn({ relativePath, content, platforms: PLATFORMS["react"] });
},
filterFn: (relativePath) => !tanstackStartOnlyTemplateFiles.has(relativePath),
});
generateFromTemplate({
src: srcDir,
dest: path.resolve(baseDir, "tanstack-start"),
editFn: (relativePath, content) => {
return baseEditFn({ relativePath, content, platforms: PLATFORMS["tanstack-start"] });
},
filterFn: (relativePath) => !templateOnlyFiles.has(relativePath),
});
}).catch((error) => {
console.error(error);

View File

@ -9,7 +9,8 @@ export const PLATFORMS = {
"next": ['next', 'react-like', 'js-like'],
"js": ['js', 'js-like'],
"react": ['react', 'react-like', 'js-like'],
"template": ['template', 'react-like', 'next', 'js', 'js-like', 'python-like'],
"tanstack-start": ['tanstack-start', 'react', 'react-like', 'js-like'],
"template": ['template', 'react-like', 'next', 'js', 'js-like', 'python-like', 'tanstack-start'],
"python": ['python', 'python-like'],
}

View File

@ -4,6 +4,7 @@
"STACK_*",
"CRON_SECRET",
"NEXT_PUBLIC_*",
"VITE_*",
"NEXT_PUBLIC_SENTRY_*",
"SENTRY_*",
"VERCEL_GIT_COMMIT_SHA",