diff --git a/examples/react/start-basic-ory/.gitignore b/examples/react/start-basic-ory/.gitignore new file mode 100644 index 00000000000..7c820af2b32 --- /dev/null +++ b/examples/react/start-basic-ory/.gitignore @@ -0,0 +1,83 @@ +.vite +# See https://help.github.com/ignore-files/ for more about ignoring files. + +# dependencies +node_modules +package-lock.json +yarn.lock + +# builds +types +build +*/build +dist +.output +lib +es +artifacts +.rpt2_cache +coverage +*.tgz +.wrangler + +# tests +packages/router-generator/tests/**/*.gen.ts +packages/router-generator/tests/**/*.gen.js +**/port*.txt + +# misc +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.next + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.history +size-plugin.json +stats-hydration.json +stats-react.json +stats.html +.vscode/settings.json + +*.log +.DS_Store +.cache +.pnpm-store +ts-perf + +/examples/*/*/yarn.lock +/examples/*/*/package-lock.json + +.netlify + +nx-cloud.env +.nx/cache +.nx/workspace-data + +gpt/db.json + +vite.config.timestamp-* +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +vite.config.timestamp_* +vite.config.js.timestamp_* +vite.config.ts.timestamp_* + +.idea +*.vitest-temp.json + +# Handling VSCode settings +/.vscode/ +!/examples/react/**/.vscode/settings.json + +**/llms + +**/.tanstack + +CLAUDE.md \ No newline at end of file diff --git a/examples/react/start-basic-ory/README.md b/examples/react/start-basic-ory/README.md new file mode 100644 index 00000000000..2d7589aa1f7 --- /dev/null +++ b/examples/react/start-basic-ory/README.md @@ -0,0 +1,108 @@ +# TanStack Start - Ory Auth Example + +A TanStack Start example demonstrating authentication with [Ory Kratos](https://www.ory.com/docs/kratos/quickstart). +Routes are guarded via route context, and all Ory API calls are made through server functions using the +[`@ory/client-fetch`](https://github.com/ory/sdk) SDK. + +- [TanStack Router Docs](https://tanstack.com/router) +- [Ory Kratos Docs](https://www.ory.com/docs/kratos/quickstart) +- [Ory SDK (client-fetch)](https://github.com/ory/sdk) + +## Start a new project based on this example + +```sh +npx gitpick TanStack/router/tree/main/examples/react/start-basic-ory start-basic-ory +``` + +## Prerequisites + +- [Node.js](https://nodejs.org) 20+ +- [pnpm](https://pnpm.io) +- An Ory identity provider running and accessible. See the two options below. + +## Option A — Ory Network + Ory Tunnel (recommended for new projects) + +Ory Tunnel proxies Ory Network (cloud) APIs onto `localhost:4000`, which is the same origin the app expects. +This avoids CORS and cookie-domain issues without running anything in Docker. + +1. Install the Ory CLI: + +```sh +npm install -g @ory/ory-cli +``` + +2. Log in and create a project (skip if you already have one): + +```sh +ory login +ory create project --name "my-ory-project" +``` + + Note the **project ID** and **workspace ID** that are printed. + +3. Start the tunnel. It exposes Ory APIs at `http://localhost:4000` by default, + which matches the `VITE_ORY_SDK_URL` default in this example: + +```sh +ory tunnel --project --workspace http://localhost:3000 +``` + + Keep this process running in a separate terminal while you develop. + +4. In another terminal, start the app (see [Getting Started](#getting-started) below). + Access it through the tunnel URL (`http://localhost:4000`) rather than `http://localhost:3000` + so that cookies and CSRF tokens share the same domain. + +## Option B — Self-hosted Kratos in Docker + +Use this option if you want a fully local setup with no cloud dependency. + +1. Clone the Kratos repo and run the standalone quickstart: + +```sh +git clone --depth 1 https://github.com/ory/kratos.git +cd kratos +docker-compose -f quickstart.yml -f quickstart-standalone.yml up --force-recreate +``` + + This starts: + - Kratos public API on `http://localhost:4433` + - Kratos admin API on `http://localhost:4434` + - The Ory self-service UI on `http://localhost:4455` + +2. Set `VITE_ORY_SDK_URL` to point at the Kratos public API: + +```sh +export VITE_ORY_SDK_URL=http://localhost:4433 +``` + +3. Start the app (see [Getting Started](#getting-started) below). + + > **Note:** In standalone mode the self-service UI (`localhost:4455`) handles login and + > registration pages. Your app's Login link will redirect there. Cookie domains work + > because everything runs on `127.0.0.1`. + +## Getting Started + +From your terminal: + +```sh +pnpm install +pnpm dev +``` + +This starts the app in development mode on `http://localhost:3000`, rebuilding assets on file changes. + +## Build + +To build the app for production: + +```sh +pnpm build +``` + +To preview the production build locally: + +```sh +pnpm preview +``` diff --git a/examples/react/start-basic-ory/package.json b/examples/react/start-basic-ory/package.json new file mode 100644 index 00000000000..f08217a12e2 --- /dev/null +++ b/examples/react/start-basic-ory/package.json @@ -0,0 +1,30 @@ +{ + "name": "tanstack-start-example-bare", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js" + }, + "dependencies": { + "@ory/client-fetch": "^1.22.22", + "@tanstack/react-router": "^1.158.0", + "@tanstack/react-router-devtools": "^1.158.0", + "@tanstack/react-start": "^1.158.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^22.5.4", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^7.3.1", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/examples/react/start-basic-ory/public/favicon.ico b/examples/react/start-basic-ory/public/favicon.ico new file mode 100644 index 00000000000..1a1751676f7 Binary files /dev/null and b/examples/react/start-basic-ory/public/favicon.ico differ diff --git a/examples/react/start-basic-ory/public/ory.png b/examples/react/start-basic-ory/public/ory.png new file mode 100644 index 00000000000..d7e1a0b6e1c Binary files /dev/null and b/examples/react/start-basic-ory/public/ory.png differ diff --git a/examples/react/start-basic-ory/public/tanstack.png b/examples/react/start-basic-ory/public/tanstack.png new file mode 100644 index 00000000000..3758526f383 Binary files /dev/null and b/examples/react/start-basic-ory/public/tanstack.png differ diff --git a/examples/react/start-basic-ory/src/routeTree.gen.ts b/examples/react/start-basic-ory/src/routeTree.gen.ts new file mode 100644 index 00000000000..e8e758b4e47 --- /dev/null +++ b/examples/react/start-basic-ory/src/routeTree.gen.ts @@ -0,0 +1,111 @@ +/* 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 AuthenticatedRouteImport } from './routes/_authenticated' +import { Route as IndexRouteImport } from './routes/index' +import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile' + +const AuthenticatedRoute = AuthenticatedRouteImport.update({ + id: '/_authenticated', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => AuthenticatedRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/profile': typeof AuthenticatedProfileRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/profile': typeof AuthenticatedProfileRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/_authenticated': typeof AuthenticatedRouteWithChildren + '/_authenticated/profile': typeof AuthenticatedProfileRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/profile' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/profile' + id: '__root__' | '/' | '/_authenticated' | '/_authenticated/profile' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AuthenticatedRoute: typeof AuthenticatedRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/_authenticated': { + id: '/_authenticated' + path: '' + fullPath: '/' + preLoaderRoute: typeof AuthenticatedRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/_authenticated/profile': { + id: '/_authenticated/profile' + path: '/profile' + fullPath: '/profile' + preLoaderRoute: typeof AuthenticatedProfileRouteImport + parentRoute: typeof AuthenticatedRoute + } + } +} + +interface AuthenticatedRouteChildren { + AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute +} + +const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { + AuthenticatedProfileRoute: AuthenticatedProfileRoute, +} + +const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( + AuthenticatedRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AuthenticatedRoute: AuthenticatedRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/examples/react/start-basic-ory/src/router.tsx b/examples/react/start-basic-ory/src/router.tsx new file mode 100644 index 00000000000..a464233af90 --- /dev/null +++ b/examples/react/start-basic-ory/src/router.tsx @@ -0,0 +1,20 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultErrorComponent: (err) =>

{err.error.stack}

, + defaultNotFoundComponent: () =>

not found

, + scrollRestoration: true, + }) + + return router +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/examples/react/start-basic-ory/src/routes/__root.tsx b/examples/react/start-basic-ory/src/routes/__root.tsx new file mode 100644 index 00000000000..15686c7701a --- /dev/null +++ b/examples/react/start-basic-ory/src/routes/__root.tsx @@ -0,0 +1,93 @@ +/// +import * as React from 'react' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import appCss from '~/styles/app.css?url' +import { getOrySession, getLogoutUrl } from '~/utils/orySession' +import type { OrySession } from '~/utils/orySession' + +const ORY_BASE = import.meta.env.VITE_ORY_SDK_URL ?? 'http://localhost:4000' + +export type AuthContext = { + session: OrySession | null + isAuthenticated: boolean +} + +export const Route = createRootRoute({ + head: () => ({ + links: [{ rel: 'stylesheet', href: appCss }], + }), + beforeLoad: async () => { + const session = await getOrySession() + const isAuthenticated = !!session?.identity + return { + auth: { + session, + isAuthenticated, + } satisfies AuthContext, + } + }, + + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + ) +} + +function RootDocument({ children }: { children: React.ReactNode }) { + const { auth } = Route.useRouteContext() + + return ( + + + + + + + + {children} + + + + + ) +} diff --git a/examples/react/start-basic-ory/src/routes/_authenticated.tsx b/examples/react/start-basic-ory/src/routes/_authenticated.tsx new file mode 100644 index 00000000000..65ada99c16d --- /dev/null +++ b/examples/react/start-basic-ory/src/routes/_authenticated.tsx @@ -0,0 +1,12 @@ +import { createFileRoute, Outlet, redirect } from '@tanstack/react-router' + +const ORY_BASE = import.meta.env.VITE_ORY_SDK_URL ?? 'http://localhost:4000' + +export const Route = createFileRoute('/_authenticated')({ + beforeLoad: ({ context }) => { + if (!context.auth.isAuthenticated) { + throw redirect({ href: `${ORY_BASE}/self-service/login/browser` }) + } + }, + component: () => , +}) diff --git a/examples/react/start-basic-ory/src/routes/_authenticated/profile.tsx b/examples/react/start-basic-ory/src/routes/_authenticated/profile.tsx new file mode 100644 index 00000000000..72795a5d37b --- /dev/null +++ b/examples/react/start-basic-ory/src/routes/_authenticated/profile.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_authenticated/profile')({ + component: RouteComponent, +}) + +function RouteComponent() { + const { auth } = Route.useRouteContext() + const email = auth.session?.identity?.traits?.email + + return ( +
+
+

Profile

+

Signed in as

+

{email ?? '—'}

+
+
+ ) +} diff --git a/examples/react/start-basic-ory/src/routes/index.tsx b/examples/react/start-basic-ory/src/routes/index.tsx new file mode 100644 index 00000000000..973e6bd343e --- /dev/null +++ b/examples/react/start-basic-ory/src/routes/index.tsx @@ -0,0 +1,194 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: RouteComponent, +}) + +const badges = ['File-based Routing', 'Server Functions', 'Session Auth', 'Protected Routes'] + +function RouteComponent() { + return ( +
+
+
+ TanStack + + + Ory +
+ +

TanStack Start + Ory Kratos

+

+ Full-stack React routing with production-grade authentication +

+ +
+ {badges.map((label) => ( + {label} + ))} +
+ +
+
+
+ +
+

TanStack Start

+

+ File-based routing, server functions, and SSR — all from one + framework. Routes in /src/routes/{' '} + map directly to URLs. +

+
+ +
+ +
+
+ +
+

Ory Kratos

+

+ Headless identity management handles login, registration, and + session lifecycle. No auth logic to maintain. +

+
+
+ +

+ Sign in to unlock the{' '} + /profile route, or browse the + source to see how it's wired together. +

+
+
+ ) +} + +const styles: Record = { + page: { + minHeight: '100vh', + background: 'linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '2rem 1rem', + margin: 0, + }, + hero: { + maxWidth: 760, + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '1.75rem', + }, + logoRow: { + display: 'flex', + alignItems: 'center', + gap: '1.25rem', + }, + logo: { + width: 72, + height: 72, + objectFit: 'contain' as const, + }, + plus: { + fontSize: '2.25rem', + fontWeight: 700, + color: '#a78bfa', + }, + title: { + margin: 0, + fontSize: 'clamp(1.75rem, 4vw, 2.5rem)', + fontWeight: 700, + color: '#f1f5f9', + textAlign: 'center' as const, + letterSpacing: '-0.02em', + }, + subtitle: { + margin: 0, + fontSize: '1.1rem', + color: '#94a3b8', + textAlign: 'center' as const, + maxWidth: 480, + }, + badges: { + display: 'flex', + flexWrap: 'wrap' as const, + justifyContent: 'center' as const, + gap: '0.5rem', + }, + badge: { + background: 'rgba(139, 92, 246, 0.15)', + border: '1px solid rgba(139, 92, 246, 0.35)', + color: '#c4b5fd', + borderRadius: 20, + padding: '0.3rem 0.85rem', + fontSize: '0.82rem', + fontWeight: 500, + letterSpacing: '0.02em', + }, + cardRow: { + display: 'flex', + alignItems: 'stretch', + width: '100%', + background: 'rgba(30, 27, 75, 0.6)', + border: '1px solid rgba(139, 92, 246, 0.2)', + borderRadius: 16, + overflow: 'hidden', + }, + card: { + flex: 1, + padding: '1.75rem 1.5rem', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '0.6rem', + textAlign: 'center' as const, + }, + cardIcon: { + width: 44, + height: 44, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + cardLogo: { + width: 40, + height: 40, + objectFit: 'contain' as const, + }, + cardTitle: { + margin: 0, + fontSize: '1.05rem', + fontWeight: 600, + color: '#e2e8f0', + }, + cardText: { + margin: 0, + fontSize: '0.88rem', + color: '#94a3b8', + lineHeight: 1.55, + }, + divider: { + width: 1, + background: 'rgba(139, 92, 246, 0.25)', + }, + code: { + background: 'rgba(139, 92, 246, 0.15)', + borderRadius: 4, + padding: '0.1em 0.4em', + fontSize: '0.92em', + color: '#c4b5fd', + }, + footer: { + margin: 0, + fontSize: '0.92rem', + color: '#64748b', + textAlign: 'center' as const, + }, + highlight: { + color: '#a78bfa', + fontWeight: 600, + }, +} diff --git a/examples/react/start-basic-ory/src/styles/app.css b/examples/react/start-basic-ory/src/styles/app.css new file mode 100644 index 00000000000..c7da4e65384 --- /dev/null +++ b/examples/react/start-basic-ory/src/styles/app.css @@ -0,0 +1,22 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: #0f172a; + font-family: + Gordita, Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', + sans-serif; +} + +a { + margin-right: 0; +} + +main { + text-align: center; + padding: 0; + margin: 0; +} diff --git a/examples/react/start-basic-ory/src/utils/orySession.ts b/examples/react/start-basic-ory/src/utils/orySession.ts new file mode 100644 index 00000000000..39fb352356d --- /dev/null +++ b/examples/react/start-basic-ory/src/utils/orySession.ts @@ -0,0 +1,48 @@ +import { createServerFn } from '@tanstack/react-start' +import { getRequest } from '@tanstack/react-start/server' +import { Configuration, FrontendApi } from '@ory/client-fetch' +import type { Session } from '@ory/client-fetch' + +export type { Session as OrySession } + +const ORY_BASE = process.env.VITE_ORY_SDK_URL ?? 'http://localhost:4000' + +const oryClient = new FrontendApi(new Configuration({ basePath: ORY_BASE })) + +const SESSION_CACHE_TTL = 600_000 // 10 minutes +const sessionCache = new Map() + +export const getOrySession = createServerFn({ method: 'GET' }).handler(async () => { + const req = getRequest() + const cookie = req.headers.get('cookie') ?? '' + + const cached = sessionCache.get(cookie) + if (cached && Date.now() < cached.expiresAt) { + return cached.data + } + + // Clean up any stale entry for this key before fetching + sessionCache.delete(cookie) + + let data: Session | null + try { + data = await oryClient.toSession({ cookie }) + } catch { + data = null + } + + sessionCache.set(cookie, { data, expiresAt: Date.now() + SESSION_CACHE_TTL }) + return data +}) + +export const getLogoutUrl = createServerFn({ method: 'GET' }).handler(async () => { + const req = getRequest() + const cookie = req.headers.get('cookie') ?? '' + + try { + const { logout_url } = await oryClient.createBrowserLogoutFlow({ cookie }) + return logout_url + } catch { + return null + } +}) diff --git a/examples/react/start-basic-ory/tsconfig.json b/examples/react/start-basic-ory/tsconfig.json new file mode 100644 index 00000000000..b3a2d67dfa6 --- /dev/null +++ b/examples/react/start-basic-ory/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx", "public/script*.js"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/examples/react/start-basic-ory/vite.config.ts b/examples/react/start-basic-ory/vite.config.ts new file mode 100644 index 00000000000..f10c86e79fc --- /dev/null +++ b/examples/react/start-basic-ory/vite.config.ts @@ -0,0 +1,17 @@ +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import viteReact from '@vitejs/plugin-react' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +})