├── .gitignore
├── .prettierrc
├── postcss.config.js
├── README.md
├── app
├── db
│ └── index.ts
├── locales
│ ├── en
│ │ └── translation.json
│ └── fr
│ │ └── translation.json
├── api.ts
├── client.tsx
├── ssr.tsx
├── services
│ ├── auth
│ │ ├── redis-session.ts
│ │ ├── schemas.ts
│ │ └── index.ts
│ └── posts.ts
├── middleware.tsx
├── components
│ ├── NotFound.tsx
│ ├── ui
│ │ ├── label.tsx
│ │ ├── input.tsx
│ │ ├── sonner.tsx
│ │ ├── alert.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── form.tsx
│ │ └── dropdown-menu.tsx
│ ├── LocaleToggle.tsx
│ ├── DefaultCatchBoundary.tsx
│ └── theme
│ │ ├── ThemeScript.ts
│ │ └── ThemeProvider.tsx
├── lib
│ ├── utils.ts
│ └── i18n.ts
├── routes
│ ├── _auth.tsx
│ ├── index.tsx
│ ├── __root.tsx
│ └── auth
│ │ ├── login.tsx
│ │ └── register.tsx
├── router.tsx
├── styles
│ └── globals.css
└── routeTree.gen.ts
├── env.d.ts
├── .env.example
├── components.json
├── tsconfig.json
├── .eslintrc.cjs
├── app.config.ts
├── prisma
└── schema.prisma
├── tailwind.config.js
├── package.json
└── i18next-parser.config.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env
3 | .vinxi
4 | .vercel
5 | .nitro
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all",
4 | "semi": true,
5 | "tabWidth": 2
6 | }
7 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TanStack starter
2 |
3 | This is a small starter for those wanting to jump-start their project with the new TanStack Start.
4 |
--------------------------------------------------------------------------------
/app/db/index.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 |
3 | const prisma = new PrismaClient();
4 |
5 | export default prisma;
6 |
--------------------------------------------------------------------------------
/env.d.ts:
--------------------------------------------------------------------------------
1 | import type { Auth } from '@/lib/auth/types';
2 |
3 | declare module 'vinxi/http' {
4 | interface H3EventContext {
5 | auth: Auth;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/app/locales/en/translation.json:
--------------------------------------------------------------------------------
1 | {
2 | "Home": {
3 | "title": "Welcome to TanStack starter!"
4 | },
5 | "language.en": "English",
6 | "language.fr": "French"
7 | }
8 |
--------------------------------------------------------------------------------
/app/locales/fr/translation.json:
--------------------------------------------------------------------------------
1 | {
2 | "Home": {
3 | "title": "Bienvenue dans le starter de TanStack !"
4 | },
5 | "language.en": "Anglais",
6 | "language.fr": "Français"
7 | }
8 |
--------------------------------------------------------------------------------
/app/api.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createStartAPIHandler,
3 | defaultAPIFileRouteHandler,
4 | } from '@tanstack/start/api';
5 |
6 | export default createStartAPIHandler(defaultAPIFileRouteHandler);
7 |
--------------------------------------------------------------------------------
/app/client.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import { hydrateRoot } from 'react-dom/client';
3 | import { StartClient } from '@tanstack/start';
4 | import { createRouter } from './router';
5 |
6 | const router = createRouter();
7 |
8 | hydrateRoot(document.getElementById('root')!, );
9 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # This is an example environment file. With Tanstack Start, any environment variables prefixed
2 | # by VITE_ will be exposed to your client-side code. Everything else will be server-side.
3 | DATABASE_URL="postgresql://user:pass@localhost:5432/foo"
4 |
5 | BETTER_AUTH_SECRET=my_super_secret_here
6 | BETTER_AUTH_URL=http://localhost:3000
7 |
--------------------------------------------------------------------------------
/app/ssr.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import {
3 | createStartHandler,
4 | defaultStreamHandler,
5 | } from '@tanstack/start/server';
6 | import { getRouterManifest } from '@tanstack/start/router-manifest';
7 |
8 | import { createRouter } from './router';
9 |
10 | export default createStartHandler({
11 | createRouter,
12 | getRouterManifest,
13 | })(defaultStreamHandler);
14 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/styles/app.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "hooks": "@/hooks",
18 | "lib": "@/lib"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/services/auth/redis-session.ts:
--------------------------------------------------------------------------------
1 | // import { createClient } from 'redis';
2 | // import type { SecondaryStorage } from 'better-auth';
3 |
4 | // const redis = createClient();
5 |
6 | // const redisSecondaryStorage: SecondaryStorage = {
7 | // get: async (key) => await redis.get(key),
8 | // set: async (key, value, ttl) => {
9 | // if (ttl) await redis.set(key, value, { EX: ttl });
10 | // else await redis.set(key, value);
11 | // },
12 | // delete: async (key) => await redis.del(key),
13 | // };
14 |
15 | // export default redisSecondaryStorage;
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "esModuleInterop": true,
5 | "jsx": "react-jsx",
6 | "module": "ESNext",
7 | "moduleResolution": "Bundler",
8 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
9 | "isolatedModules": true,
10 | "resolveJsonModule": true,
11 | "skipLibCheck": true,
12 | "target": "ES2022",
13 | "allowJs": true,
14 | "baseUrl": ".",
15 | "strictNullChecks": true,
16 | "paths": {
17 | "@/*": ["./app/*"]
18 | }
19 | },
20 | "include": ["**/*.ts", "**/*.tsx"]
21 | }
22 |
--------------------------------------------------------------------------------
/app/middleware.tsx:
--------------------------------------------------------------------------------
1 | import { defineMiddleware } from 'vinxi/http';
2 | import { auth } from '@/services/auth';
3 |
4 | export default defineMiddleware({
5 | onRequest: async (event) => {
6 | const session = await auth.api.getSession({
7 | headers: event.headers,
8 | });
9 |
10 | const authResult = !session
11 | ? { isAuthenticated: false, user: null, session: null }
12 | : {
13 | isAuthenticated: true,
14 | user: session.user,
15 | session: session.session,
16 | };
17 |
18 | event.context.auth = authResult;
19 | },
20 | });
21 |
--------------------------------------------------------------------------------
/app/components/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@tanstack/react-router';
2 | import { Button } from './ui/button';
3 |
4 | export function NotFound() {
5 | return (
6 |
7 |
The page you are looking for does not exist.
8 |
9 |
12 |
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/app/services/auth/schemas.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | const signUpEmailSchema = z.object({
4 | name: z.string(),
5 | email: z.string().email(),
6 | password: z.string().min(8),
7 | });
8 |
9 | type SignUpEmailInput = z.infer;
10 |
11 | const signInEmailSchema = z.object({
12 | email: z.string().email(),
13 | password: z.string().min(8),
14 | });
15 |
16 | type SignInEmailInput = z.infer;
17 |
18 | const authSchemas = {
19 | signUp: signUpEmailSchema,
20 | signIn: signInEmailSchema,
21 | };
22 |
23 | export type { SignUpEmailInput, SignInEmailInput };
24 | export { authSchemas };
25 |
--------------------------------------------------------------------------------
/app/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | /**
9 | * Helper function to convert a string, Date, or object to a Date object.
10 | * This is useful in TanStack Start because currently all dates get serialized to strings.
11 | * @returns Date
12 | */
13 | export function actualDate(dateOrString: object | Date | string): Date {
14 | if (typeof dateOrString === 'string') return new Date(dateOrString);
15 | if (typeof dateOrString === 'object') return dateOrString as Date;
16 | return dateOrString;
17 | }
18 |
--------------------------------------------------------------------------------
/app/routes/_auth.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, redirect, Outlet } from '@tanstack/react-router';
2 | import { zodSearchValidator } from '@tanstack/router-zod-adapter';
3 | import { z } from 'zod';
4 |
5 | function AuthLayout() {
6 | return (
7 | <>
8 |
9 | >
10 | );
11 | }
12 |
13 | export const Route = createFileRoute('/_auth')({
14 | validateSearch: zodSearchValidator(
15 | z.object({
16 | callbackUrl: z.string().default('/'),
17 | }),
18 | ),
19 | beforeLoad: async ({ context, search }) => {
20 | if (context.auth.isAuthenticated) {
21 | throw redirect({
22 | code: 302,
23 | to: search.callbackUrl,
24 | });
25 | }
26 | },
27 | component: AuthLayout,
28 | });
29 |
--------------------------------------------------------------------------------
/app/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as LabelPrimitive from '@radix-ui/react-label';
5 | import { cva, type VariantProps } from 'class-variance-authority';
6 |
7 | import { cn } from '@/lib/utils';
8 |
9 | const labelVariants = cva(
10 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
11 | );
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ));
24 | Label.displayName = LabelPrimitive.Root.displayName;
25 |
26 | export { Label };
27 |
--------------------------------------------------------------------------------
/app/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | },
22 | );
23 | Input.displayName = 'Input';
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/app/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from 'next-themes';
2 | import { Toaster as Sonner } from 'sonner';
3 |
4 | type ToasterProps = React.ComponentProps;
5 |
6 | const Toaster = ({ ...props }: ToasterProps) => {
7 | const { theme = 'system' } = useTheme();
8 |
9 | return (
10 |
26 | );
27 | };
28 |
29 | export { Toaster };
30 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /*
2 | * This is a custom ESLint configuration for use with
3 | * internal (bundled by their consumer) libraries
4 | * that utilize React.
5 | */
6 |
7 | /** @type {import("eslint").Linter.Config} */
8 | module.exports = {
9 | extends: [
10 | 'eslint:recommended',
11 | 'plugin:@typescript-eslint/recommended',
12 | 'plugin:react/recommended',
13 | 'plugin:react-hooks/recommended',
14 | 'prettier',
15 | ],
16 | // plugins: ['only-warn'],
17 | globals: {
18 | React: true,
19 | JSX: true,
20 | },
21 | env: {
22 | browser: true,
23 | },
24 | ignorePatterns: [
25 | // Ignore dotfiles
26 | '.*.js',
27 | 'node_modules/',
28 | 'dist/',
29 | ],
30 | overrides: [
31 | // Force ESLint to detect .tsx files
32 | { files: ['*.js?(x)', '*.ts?(x)'] },
33 | ],
34 | rules: {
35 | // Disabled due to buggy-ness with Shadcn
36 | 'react/prop-types': 'off',
37 | 'no-console': 'warn',
38 | '@typescript-eslint/consistent-type-imports': 'error',
39 | },
40 | };
41 |
--------------------------------------------------------------------------------
/app/components/LocaleToggle.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { Button } from '@/components/ui/button';
3 | import {
4 | DropdownMenu,
5 | DropdownMenuContent,
6 | DropdownMenuItem,
7 | DropdownMenuTrigger,
8 | } from '@/components/ui/dropdown-menu';
9 | import { supportedLocales } from '@/lib/i18n';
10 | import { useTranslation } from 'react-i18next';
11 | import { LucideLanguages } from 'lucide-react';
12 |
13 | const LocaleToggle: FC = () => {
14 | const { t, i18n } = useTranslation();
15 |
16 | return (
17 |
18 |
19 |
22 |
23 |
24 | {supportedLocales.map((locale) => (
25 | i18n.changeLanguage(locale)}
28 | >
29 | {t(`language.${locale}`, { defaultValue: locale })}
30 |
31 | ))}
32 |
33 |
34 | );
35 | };
36 |
37 | export default LocaleToggle;
38 |
--------------------------------------------------------------------------------
/app/router.tsx:
--------------------------------------------------------------------------------
1 | import { createRouter as createTanStackRouter } from '@tanstack/react-router';
2 | import { routerWithQueryClient } from '@tanstack/react-router-with-query';
3 | import { routeTree } from './routeTree.gen';
4 | import { notifyManager, QueryClient } from '@tanstack/react-query';
5 | import { DefaultCatchBoundary } from '@/components/DefaultCatchBoundary';
6 | import { NotFound } from '@/components/NotFound';
7 |
8 | export function createRouter() {
9 | if (typeof document !== 'undefined') {
10 | notifyManager.setScheduler(window.requestAnimationFrame);
11 | }
12 |
13 | const queryClient: QueryClient = new QueryClient({
14 | defaultOptions: {
15 | queries: {
16 | refetchOnReconnect: () => !queryClient.isMutating(),
17 | },
18 | },
19 | });
20 |
21 | return routerWithQueryClient(
22 | createTanStackRouter({
23 | routeTree,
24 | context: { queryClient },
25 | defaultPreload: 'intent',
26 | defaultErrorComponent: DefaultCatchBoundary,
27 | defaultNotFoundComponent: NotFound,
28 | }),
29 | queryClient,
30 | );
31 | }
32 |
33 | declare module '@tanstack/react-router' {
34 | interface Register {
35 | router: ReturnType;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/app/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import LocaleToggle from '@/components/LocaleToggle';
4 | import { Button } from '@/components/ui/button';
5 | import { useAuthQuery } from '@/services/auth';
6 | import { createFileRoute, Link } from '@tanstack/react-router';
7 |
8 | const HeroHeader: FC = () => {
9 | const { data: auth } = useAuthQuery();
10 | const { t } = useTranslation();
11 |
12 | return (
13 |
14 |
15 |
16 |
19 |
20 |
21 |
{t('Home.title', `Welcome to TanStack starter!`)}
22 |
23 |
{JSON.stringify(auth, null, 2)}
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | function Home() {
31 | return ;
32 | }
33 |
34 | export const Route = createFileRoute('/')({
35 | component: Home,
36 | });
37 |
--------------------------------------------------------------------------------
/app.config.ts:
--------------------------------------------------------------------------------
1 | import { createRequire } from 'module';
2 | import path from 'path';
3 | import { defineConfig } from '@tanstack/start/config';
4 | import tsconfigPaths from 'vite-tsconfig-paths';
5 | import { type App } from 'vinxi';
6 |
7 | const require = createRequire(import.meta.url);
8 | const prismaClientDirectory = path.normalize(
9 | path.relative(
10 | process.cwd(),
11 | require
12 | .resolve('@prisma/client')
13 | .replace(/@prisma(\/|\\)client(\/|\\).*/, '.prisma/client'),
14 | ),
15 | );
16 | const prismaIndexBrowserPath = path.join(
17 | prismaClientDirectory,
18 | 'index-browser.js',
19 | );
20 |
21 | const startConfig = defineConfig({
22 | server: {
23 | preset: 'vercel',
24 | },
25 | vite: {
26 | plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })],
27 | resolve: {
28 | alias: {
29 | '.prisma/client/index-browser': prismaIndexBrowserPath,
30 | },
31 | },
32 | },
33 | });
34 |
35 | const routers = startConfig.config.routers.map((r) => {
36 | return {
37 | ...r,
38 | middleware: r.target === 'server' ? './app/middleware.tsx' : undefined,
39 | };
40 | });
41 |
42 | const app: App = {
43 | ...startConfig,
44 | config: {
45 | ...startConfig.config,
46 | routers: routers,
47 | },
48 | };
49 |
50 | export default app;
51 |
--------------------------------------------------------------------------------
/app/components/DefaultCatchBoundary.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ErrorComponent,
3 | type ErrorComponentProps,
4 | Link,
5 | rootRouteId,
6 | useMatch,
7 | useRouter,
8 | } from '@tanstack/react-router';
9 | import { Button } from './ui/button';
10 |
11 | export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
12 | const router = useRouter();
13 | const isRoot = useMatch({
14 | strict: false,
15 | select: (state) => state.id === rootRouteId,
16 | });
17 |
18 | console.error(error);
19 |
20 | return (
21 |
22 |
23 |
24 |
32 | {isRoot ? (
33 |
36 | ) : (
37 |
48 | )}
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/app/lib/i18n.ts:
--------------------------------------------------------------------------------
1 | import { createServerFn } from '@tanstack/start';
2 | import i18n from 'i18next';
3 | import { initReactI18next } from 'react-i18next';
4 | import { getHeader } from 'vinxi/http';
5 | import { z } from 'zod';
6 | import { zodI18nMap } from 'zod-i18n-map';
7 |
8 | import en from '@/locales/en/translation.json';
9 | import fr from '@/locales/fr/translation.json';
10 |
11 | const resources = {
12 | en: { translation: en },
13 | fr: { translation: fr },
14 | } as const;
15 |
16 | type SupportedLocale = keyof typeof resources;
17 |
18 | const supportedLocales = Object.keys(resources) as SupportedLocale[];
19 |
20 | type Locale = (typeof supportedLocales)[number];
21 |
22 | const defaultLocale: Locale = 'en';
23 |
24 | const getClientLocale = (): string => {
25 | const userLanguage =
26 | 'userLanguage' in navigator ? navigator.userLanguage : null;
27 |
28 | return navigator.language || (userLanguage as string);
29 | };
30 |
31 | const getLocale = createServerFn('GET', async () => {
32 | const header = getHeader('Accept-Language');
33 | const languages = header?.split(',') ?? [];
34 |
35 | return (
36 | supportedLocales.find((lang) => languages.includes(lang)) ?? defaultLocale
37 | );
38 | });
39 |
40 | i18n.use(initReactI18next).init({
41 | fallbackLng: defaultLocale,
42 | supportedLngs: supportedLocales,
43 | debug: import.meta.env.DEV,
44 | lng: getClientLocale(),
45 |
46 | saveMissing: true,
47 | saveMissingTo: 'current',
48 | resources,
49 | });
50 |
51 | z.setErrorMap(zodI18nMap);
52 |
53 | export { getLocale, supportedLocales };
54 | export default i18n;
55 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | }
10 |
11 | datasource db {
12 | provider = "postgresql"
13 | url = env("DATABASE_URL")
14 | }
15 |
16 | model User {
17 | id String @id
18 | name String
19 | email String
20 | emailVerified Boolean
21 | image String?
22 | createdAt DateTime
23 | updatedAt DateTime
24 | Session Session[]
25 | Account Account[]
26 |
27 | @@unique([email])
28 | @@map("user")
29 | }
30 |
31 | model Session {
32 | id String @id
33 | expiresAt DateTime
34 | ipAddress String?
35 | userAgent String?
36 | userId String
37 | users User @relation(fields: [userId], references: [id], onDelete: Cascade)
38 |
39 | @@map("session")
40 | }
41 |
42 | model Account {
43 | id String @id
44 | accountId String
45 | providerId String
46 | userId String
47 | users User @relation(fields: [userId], references: [id], onDelete: Cascade)
48 | accessToken String?
49 | refreshToken String?
50 | idToken String?
51 | expiresAt DateTime?
52 | password String?
53 |
54 | @@map("account")
55 | }
56 |
57 | model Verification {
58 | id String @id
59 | identifier String
60 | value String
61 | expiresAt DateTime
62 |
63 | @@map("verification")
64 | }
65 |
--------------------------------------------------------------------------------
/app/components/theme/ThemeScript.ts:
--------------------------------------------------------------------------------
1 | /*
2 | This file is adapted from next-themes to work with tanstack start.
3 | next-themes can be found at https://github.com/pacocoursey/next-themes under the MIT license.
4 | */
5 |
6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
7 | const ThemeScript: (...args: any[]) => void = (
8 | attribute,
9 | storageKey,
10 | defaultTheme,
11 | forcedTheme,
12 | themes,
13 | value,
14 | enableSystem,
15 | enableColorScheme,
16 | ) => {
17 | const el = document.documentElement;
18 | const systemThemes = ['light', 'dark'];
19 | const isClass = attribute === 'class';
20 | const classes =
21 | isClass && value
22 | ? themes.map((t: string | number) => value[t] || t)
23 | : themes;
24 |
25 | function updateDOM(theme: string) {
26 | if (isClass) {
27 | el.classList.remove(...classes);
28 | el.classList.add(theme);
29 | } else {
30 | el.setAttribute(attribute, theme);
31 | }
32 |
33 | setColorScheme(theme);
34 | }
35 |
36 | function setColorScheme(theme: string) {
37 | if (enableColorScheme && systemThemes.includes(theme)) {
38 | el.style.colorScheme = theme;
39 | }
40 | }
41 |
42 | function getSystemTheme() {
43 | return window.matchMedia('(prefers-color-scheme: dark)').matches
44 | ? 'dark'
45 | : 'light';
46 | }
47 |
48 | if (forcedTheme) {
49 | updateDOM(forcedTheme);
50 | } else {
51 | try {
52 | const themeName = localStorage.getItem(storageKey) || defaultTheme;
53 | const isSystem = enableSystem && themeName === 'system';
54 | const theme = isSystem ? getSystemTheme() : themeName;
55 | updateDOM(theme);
56 | } catch (e) {
57 | //
58 | }
59 | }
60 | };
61 |
62 | export default ThemeScript;
63 |
--------------------------------------------------------------------------------
/app/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const alertVariants = cva(
7 | 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'bg-background text-foreground',
12 | destructive:
13 | 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
14 | },
15 | },
16 | defaultVariants: {
17 | variant: 'default',
18 | },
19 | },
20 | );
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ));
33 | Alert.displayName = 'Alert';
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ));
45 | AlertTitle.displayName = 'AlertTitle';
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ));
57 | AlertDescription.displayName = 'AlertDescription';
58 |
59 | export { Alert, AlertTitle, AlertDescription };
60 |
--------------------------------------------------------------------------------
/app/services/posts.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file contains an example minimal service for Posts.
3 | */
4 |
5 | // import { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
6 | // import { createServerFn } from "@tanstack/start";
7 | // import { z } from "zod";
8 |
9 | // import prisma from '@/db';
10 | // import {
11 | // queryOptions,
12 | // } from '@tanstack/react-query';
13 | // import { createServerFn } from '@tanstack/start';
14 |
15 | // const getPosts = createServerFn('GET', async () => {
16 | // const all = await prisma.post.findMany();
17 |
18 | // return all;
19 | // });
20 |
21 | // const createPost = createServerFn("POST", async (data: z.infer) => {
22 | // return prisma.post.create({
23 | // data: {
24 | // content: data.content,
25 | // author: {
26 | // connect: {
27 | // id: data.author
28 | // }
29 | // }
30 | // }
31 | // })
32 | // });
33 |
34 | // const postQueries = {
35 | // getAll: () =>
36 | // queryOptions({
37 | // queryKey: ['posts', 'all'],
38 | // queryFn: () => getPosts(),
39 | // }),
40 | // } as const;
41 |
42 | // const createPostSchema = z.object({
43 | // content: z.string(),
44 | // author: z.number(),
45 | // });
46 |
47 | // const useCreatePostMutation = () => {
48 | // const queryClient = useQueryClient();
49 | // return useMutation>({
50 | // mutationFn: createPost,
51 | // onMutate: async () => {
52 | // await queryClient.cancelQueries({ queryKey: ['posts'] });
53 | // await queryClient.invalidateQueries({ queryKey: ['posts', 'all'] });
54 | // }
55 | // })
56 | // }
57 |
58 | // const postSchemas = {
59 | // createPost: createPostSchema,
60 | // } as const;
61 |
62 | // export { postQueries, postSchemas, useCreatePostMutation };
63 |
--------------------------------------------------------------------------------
/app/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 0 0% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 0 0% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 0 0% 3.9%;
13 | --primary: 0 0% 9%;
14 | --primary-foreground: 0 0% 98%;
15 | --secondary: 0 0% 96.1%;
16 | --secondary-foreground: 0 0% 9%;
17 | --muted: 0 0% 96.1%;
18 | --muted-foreground: 0 0% 45.1%;
19 | --accent: 0 0% 96.1%;
20 | --accent-foreground: 0 0% 9%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 0 0% 89.8%;
24 | --input: 0 0% 89.8%;
25 | --ring: 0 0% 3.9%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark {
35 | --background: 0 0% 3.9%;
36 | --foreground: 0 0% 98%;
37 | --card: 0 0% 3.9%;
38 | --card-foreground: 0 0% 98%;
39 | --popover: 0 0% 3.9%;
40 | --popover-foreground: 0 0% 98%;
41 | --primary: 0 0% 98%;
42 | --primary-foreground: 0 0% 9%;
43 | --secondary: 0 0% 14.9%;
44 | --secondary-foreground: 0 0% 98%;
45 | --muted: 0 0% 14.9%;
46 | --muted-foreground: 0 0% 63.9%;
47 | --accent: 0 0% 14.9%;
48 | --accent-foreground: 0 0% 98%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 0 0% 98%;
51 | --border: 0 0% 14.9%;
52 | --input: 0 0% 14.9%;
53 | --ring: 0 0% 83.1%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 | }
61 |
62 | @layer base {
63 | * {
64 | @apply border-border;
65 | }
66 | body {
67 | @apply bg-background text-foreground;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/app/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Slot } from '@radix-ui/react-slot';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90',
13 | destructive:
14 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
15 | outline:
16 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
17 | secondary:
18 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
19 | ghost: 'hover:bg-accent hover:text-accent-foreground',
20 | link: 'text-primary underline-offset-4 hover:underline',
21 | },
22 | size: {
23 | default: 'h-10 px-4 py-2',
24 | sm: 'h-9 rounded-md px-3',
25 | lg: 'h-11 rounded-md px-8',
26 | icon: 'h-10 w-10',
27 | },
28 | },
29 | defaultVariants: {
30 | variant: 'default',
31 | size: 'default',
32 | },
33 | },
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean;
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : 'button';
45 | return (
46 |
51 | );
52 | },
53 | );
54 | Button.displayName = 'Button';
55 |
56 | export { Button, buttonVariants };
57 |
--------------------------------------------------------------------------------
/app/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = 'Card';
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = 'CardHeader';
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ));
45 | CardTitle.displayName = 'CardTitle';
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ));
57 | CardDescription.displayName = 'CardDescription';
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ));
65 | CardContent.displayName = 'CardContent';
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ));
77 | CardFooter.displayName = 'CardFooter';
78 |
79 | export {
80 | Card,
81 | CardHeader,
82 | CardFooter,
83 | CardTitle,
84 | CardDescription,
85 | CardContent,
86 | };
87 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const { fontFamily } = require('tailwindcss/defaultTheme');
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | darkMode: ['class'],
6 | content: [
7 | './pages/**/*.{ts,tsx}',
8 | './components/**/*.{ts,tsx}',
9 | './app/**/*.{ts,tsx}',
10 | './src/**/*.{ts,tsx}',
11 | ],
12 | theme: {
13 | container: {
14 | center: 'true',
15 | padding: '2rem',
16 | screens: {
17 | '2xl': '1400px'
18 | }
19 | },
20 | extend: {
21 | colors: {
22 | border: 'hsl(var(--border))',
23 | input: 'hsl(var(--input))',
24 | ring: 'hsl(var(--ring))',
25 | background: 'hsl(var(--background))',
26 | foreground: 'hsl(var(--foreground))',
27 | primary: {
28 | DEFAULT: 'hsl(var(--primary))',
29 | foreground: 'hsl(var(--primary-foreground))'
30 | },
31 | secondary: {
32 | DEFAULT: 'hsl(var(--secondary))',
33 | foreground: 'hsl(var(--secondary-foreground))'
34 | },
35 | destructive: {
36 | DEFAULT: 'hsl(var(--destructive))',
37 | foreground: 'hsl(var(--destructive-foreground))'
38 | },
39 | muted: {
40 | DEFAULT: 'hsl(var(--muted))',
41 | foreground: 'hsl(var(--muted-foreground))'
42 | },
43 | accent: {
44 | DEFAULT: 'hsl(var(--accent))',
45 | foreground: 'hsl(var(--accent-foreground))'
46 | },
47 | popover: {
48 | DEFAULT: 'hsl(var(--popover))',
49 | foreground: 'hsl(var(--popover-foreground))'
50 | },
51 | card: {
52 | DEFAULT: 'hsl(var(--card))',
53 | foreground: 'hsl(var(--card-foreground))'
54 | }
55 | },
56 | borderRadius: {
57 | lg: 'var(--radius)',
58 | md: 'calc(var(--radius) - 2px)',
59 | sm: 'calc(var(--radius) - 4px)'
60 | },
61 | fontFamily: {
62 | sans: ['Geist Variable', ...fontFamily.sans],
63 | mono: ['Geist Mono Variable', ...fontFamily.mono]
64 | },
65 | keyframes: {
66 | 'accordion-down': {
67 | from: {
68 | height: '0'
69 | },
70 | to: {
71 | height: 'var(--radix-accordion-content-height)'
72 | }
73 | },
74 | 'accordion-up': {
75 | from: {
76 | height: 'var(--radix-accordion-content-height)'
77 | },
78 | to: {
79 | height: '0'
80 | }
81 | }
82 | },
83 | animation: {
84 | 'accordion-down': 'accordion-down 0.2s ease-out',
85 | 'accordion-up': 'accordion-up 0.2s ease-out'
86 | }
87 | }
88 | },
89 | plugins: [require('tailwindcss-animate')],
90 | };
91 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tanstack-starter",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "vinxi dev",
9 | "build": "vinxi build",
10 | "start": "vinxi start",
11 | "lint": "eslint . --ext .ts,.tsx",
12 | "ui": "pnpm dlx shadcn@latest",
13 | "db:push": "prisma migrate dev",
14 | "db:generate": "prisma generate",
15 | "postinstall": "prisma generate",
16 | "messages:extract": "i18next --config i18next-parser.config.ts"
17 | },
18 | "keywords": [],
19 | "author": "",
20 | "license": "ISC",
21 | "dependencies": {
22 | "@hookform/resolvers": "^3.9.1",
23 | "@prisma/client": "5.20.0",
24 | "@radix-ui/react-dropdown-menu": "^2.1.2",
25 | "@radix-ui/react-label": "^2.1.0",
26 | "@radix-ui/react-slot": "^1.1.0",
27 | "@tanstack/react-query": "^5.59.15",
28 | "@tanstack/react-query-devtools": "^5.59.15",
29 | "@tanstack/react-router": "^1.74.6",
30 | "@tanstack/react-router-with-query": "^1.74.6",
31 | "@tanstack/router-zod-adapter": "^1.76.1",
32 | "@tanstack/start": "^1.74.6",
33 | "@vitejs/plugin-react": "^4.3.2",
34 | "better-auth": "0.5.3-beta.7",
35 | "class-variance-authority": "^0.7.0",
36 | "clsx": "^2.1.1",
37 | "i18next": "^23.16.4",
38 | "lucide-react": "^0.447.0",
39 | "next-themes": "^0.3.0",
40 | "non.geist": "^1.0.3",
41 | "prisma": "^5.20.0",
42 | "react": "^18.3.1",
43 | "react-dom": "^18.3.1",
44 | "react-hook-form": "^7.53.1",
45 | "react-i18next": "^15.1.0",
46 | "sonner": "^1.5.0",
47 | "tailwind-merge": "^2.5.3",
48 | "tailwindcss-animate": "^1.0.7",
49 | "vinxi": "^0.4.3",
50 | "zod": "^3.23.8",
51 | "zod-i18n-map": "^2.27.0"
52 | },
53 | "devDependencies": {
54 | "@types/react": "^18.3.11",
55 | "@types/react-dom": "^18.3.0",
56 | "@typescript-eslint/eslint-plugin": "^7.1.0",
57 | "@typescript-eslint/parser": "^7.1.0",
58 | "autoprefixer": "^10.4.20",
59 | "eslint": "^8.57.1",
60 | "eslint-config-airbnb": "19.0.4",
61 | "eslint-config-prettier": "^9.1.0",
62 | "eslint-plugin-import": "^2.25.3",
63 | "eslint-plugin-jsx-a11y": "^6.5.1",
64 | "eslint-plugin-only-warn": "^1.1.0",
65 | "eslint-plugin-react": "^7.37.1",
66 | "eslint-plugin-react-hooks": "^4.6.2",
67 | "globals": "^15.10.0",
68 | "i18next-parser": "^9.0.2",
69 | "postcss": "^8.4.47",
70 | "tailwindcss": "^3.4.13",
71 | "typescript": "^5.6.2",
72 | "vite-tsconfig-paths": "^5.0.1"
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/app/routes/__root.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react';
2 | import {
3 | createRootRouteWithContext,
4 | useRouteContext,
5 | } from '@tanstack/react-router';
6 | import { Outlet, ScrollRestoration } from '@tanstack/react-router';
7 | import { Body, Head, Html, Meta, Scripts } from '@tanstack/start';
8 | import { ThemeProvider } from '@/components/theme/ThemeProvider';
9 | import type { QueryClient } from '@tanstack/react-query';
10 | import { getAuthQueryOptions } from '@/services/auth';
11 | import { I18nextProvider } from 'react-i18next';
12 | import i18n, { getLocale } from '@/lib/i18n';
13 |
14 | import '@/lib/i18n';
15 | import globalCss from '@/styles/globals.css?url';
16 | import geist from 'non.geist?url';
17 | import geistMono from 'non.geist/mono?url';
18 |
19 | export const Route = createRootRouteWithContext<{
20 | queryClient: QueryClient;
21 | }>()({
22 | meta: () => [
23 | {
24 | charSet: 'utf-8',
25 | },
26 | {
27 | name: 'viewport',
28 | content: 'width=device-width, initial-scale=1',
29 | },
30 | {
31 | title: 'TanStack Start Starter',
32 | },
33 | ],
34 | component: RootComponent,
35 | links: () => [
36 | { rel: 'stylesheet', href: globalCss },
37 | { rel: 'stylesheet', href: geist },
38 | { rel: 'stylesheet', href: geistMono },
39 | ],
40 | scripts: () =>
41 | import.meta.env.DEV
42 | ? [
43 | {
44 | type: 'module',
45 | children: `import RefreshRuntime from "/_build/@react-refresh";
46 | RefreshRuntime.injectIntoGlobalHook(window)
47 | window.$RefreshReg$ = () => {}
48 | window.$RefreshSig$ = () => (type) => type`,
49 | },
50 | ]
51 | : [],
52 | beforeLoad: async ({ context }) => {
53 | const auth = await context.queryClient.ensureQueryData(
54 | getAuthQueryOptions(),
55 | );
56 |
57 | // You could also get geolocation for the locale
58 | const locale = await getLocale();
59 |
60 | return { auth, locale };
61 | },
62 | });
63 |
64 | function RootComponent() {
65 | return (
66 |
67 |
68 |
69 | );
70 | }
71 |
72 | function RootDocument({ children }: { children: ReactNode }) {
73 | const { locale } = useRouteContext({ from: '__root__' });
74 |
75 | return (
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | {children}
84 |
85 |
86 |
87 |
88 |
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/app/services/auth/index.ts:
--------------------------------------------------------------------------------
1 | import { createServerFn } from '@tanstack/start';
2 | import type { Session, User } from 'better-auth/types';
3 | import {
4 | queryOptions,
5 | useMutation,
6 | useQueryClient,
7 | useSuspenseQuery,
8 | } from '@tanstack/react-query';
9 | import { useRouter } from '@tanstack/react-router';
10 | import prisma from '@/db';
11 | import type {
12 | SignInEmailInput,
13 | SignUpEmailInput,
14 | } from '@/services/auth/schemas';
15 | import { authSchemas } from '@/services/auth/schemas';
16 | import { betterAuth } from 'better-auth';
17 | import { prismaAdapter } from 'better-auth/adapters/prisma';
18 | import { getEvent } from 'vinxi/http';
19 |
20 | const auth = betterAuth({
21 | database: prismaAdapter(prisma, { provider: 'postgresql' }),
22 | emailAndPassword: {
23 | enabled: true,
24 | },
25 | // Uncomment below line to enable Redis for caching sessions and ratelimiting
26 | // secondaryStorage: redisSecondaryStorage
27 | });
28 |
29 | type InferAuthApi =
30 | API extends keyof typeof auth.api
31 | ? Parameters<(typeof auth.api)[API]>[0]
32 | : never;
33 |
34 | type Auth =
35 | | { isAuthenticated: false; user: null; session: null }
36 | | { isAuthenticated: true; user: User; session: Session };
37 |
38 | const authKeys = {
39 | auth: () => ['auth'],
40 | };
41 |
42 | const getAuthQueryOptions = () => {
43 | return queryOptions({
44 | queryKey: authKeys.auth(),
45 | queryFn: () => getAuth(),
46 | });
47 | };
48 |
49 | const useAuthQuery = () => useSuspenseQuery(getAuthQueryOptions());
50 |
51 | const useInvalidateAuth = () => {
52 | const router = useRouter();
53 | const queryClient = useQueryClient();
54 |
55 | return async () => {
56 | await queryClient.invalidateQueries(getAuthQueryOptions());
57 | await router.invalidate();
58 | };
59 | };
60 |
61 | const getAuth = createServerFn('GET', async (): Promise => {
62 | const event = getEvent();
63 | return event.context.auth;
64 | });
65 |
66 | const signUpEmail = createServerFn(
67 | 'POST',
68 | async (input: SignUpEmailInput, ctx) => {
69 | return await auth.api.signUpEmail({
70 | headers: ctx.request.headers,
71 | body: input,
72 | asResponse: true,
73 | });
74 | },
75 | );
76 |
77 | const signInEmail = createServerFn(
78 | 'POST',
79 | async (input: SignInEmailInput, ctx) => {
80 | return await auth.api.signInEmail({
81 | headers: ctx.request.headers,
82 | body: input,
83 | asResponse: true,
84 | });
85 | },
86 | );
87 |
88 | const signOut = createServerFn('POST', async (_, ctx) => {
89 | return await auth.api.signOut({
90 | headers: ctx.request.headers,
91 | asResponse: true,
92 | });
93 | });
94 |
95 | const useSignUpMutation = () => {
96 | const invalidateAuth = useInvalidateAuth();
97 |
98 | return useMutation({
99 | mutationFn: signUpEmail,
100 | onSuccess: invalidateAuth,
101 | });
102 | };
103 |
104 | const useSignInMutation = () => {
105 | const invalidateAuth = useInvalidateAuth();
106 |
107 | return useMutation({
108 | mutationFn: signInEmail,
109 | onSuccess: invalidateAuth,
110 | });
111 | };
112 |
113 | const useSignOutMutation = () => {
114 | const invalidateAuth = useInvalidateAuth();
115 |
116 | return useMutation({
117 | mutationFn: signOut,
118 | onSuccess: invalidateAuth,
119 | });
120 | };
121 |
122 | export {
123 | auth,
124 | authSchemas,
125 | getAuth,
126 | getAuthQueryOptions,
127 | useAuthQuery,
128 | useInvalidateAuth,
129 | useSignInMutation,
130 | useSignOutMutation,
131 | useSignUpMutation,
132 | };
133 | export type { Auth, InferAuthApi };
134 |
--------------------------------------------------------------------------------
/app/routeTree.gen.ts:
--------------------------------------------------------------------------------
1 | /* prettier-ignore-start */
2 |
3 | /* eslint-disable */
4 |
5 | // @ts-nocheck
6 |
7 | // noinspection JSUnusedGlobalSymbols
8 |
9 | // This file is auto-generated by TanStack Router
10 |
11 | // Import Routes
12 |
13 | import { Route as rootRoute } from './routes/__root'
14 | import { Route as AuthImport } from './routes/_auth'
15 | import { Route as IndexImport } from './routes/index'
16 | import { Route as AuthRegisterImport } from './routes/auth/register'
17 | import { Route as AuthLoginImport } from './routes/auth/login'
18 |
19 | // Create/Update Routes
20 |
21 | const AuthRoute = AuthImport.update({
22 | id: '/_auth',
23 | getParentRoute: () => rootRoute,
24 | } as any)
25 |
26 | const IndexRoute = IndexImport.update({
27 | id: '/',
28 | path: '/',
29 | getParentRoute: () => rootRoute,
30 | } as any)
31 |
32 | const AuthRegisterRoute = AuthRegisterImport.update({
33 | id: '/auth/register',
34 | path: '/auth/register',
35 | getParentRoute: () => rootRoute,
36 | } as any)
37 |
38 | const AuthLoginRoute = AuthLoginImport.update({
39 | id: '/auth/login',
40 | path: '/auth/login',
41 | getParentRoute: () => rootRoute,
42 | } as any)
43 |
44 | // Populate the FileRoutesByPath interface
45 |
46 | declare module '@tanstack/react-router' {
47 | interface FileRoutesByPath {
48 | '/': {
49 | id: '/'
50 | path: '/'
51 | fullPath: '/'
52 | preLoaderRoute: typeof IndexImport
53 | parentRoute: typeof rootRoute
54 | }
55 | '/_auth': {
56 | id: '/_auth'
57 | path: ''
58 | fullPath: ''
59 | preLoaderRoute: typeof AuthImport
60 | parentRoute: typeof rootRoute
61 | }
62 | '/auth/login': {
63 | id: '/auth/login'
64 | path: '/auth/login'
65 | fullPath: '/auth/login'
66 | preLoaderRoute: typeof AuthLoginImport
67 | parentRoute: typeof rootRoute
68 | }
69 | '/auth/register': {
70 | id: '/auth/register'
71 | path: '/auth/register'
72 | fullPath: '/auth/register'
73 | preLoaderRoute: typeof AuthRegisterImport
74 | parentRoute: typeof rootRoute
75 | }
76 | }
77 | }
78 |
79 | // Create and export the route tree
80 |
81 | export interface FileRoutesByFullPath {
82 | '/': typeof IndexRoute
83 | '': typeof AuthRoute
84 | '/auth/login': typeof AuthLoginRoute
85 | '/auth/register': typeof AuthRegisterRoute
86 | }
87 |
88 | export interface FileRoutesByTo {
89 | '/': typeof IndexRoute
90 | '': typeof AuthRoute
91 | '/auth/login': typeof AuthLoginRoute
92 | '/auth/register': typeof AuthRegisterRoute
93 | }
94 |
95 | export interface FileRoutesById {
96 | __root__: typeof rootRoute
97 | '/': typeof IndexRoute
98 | '/_auth': typeof AuthRoute
99 | '/auth/login': typeof AuthLoginRoute
100 | '/auth/register': typeof AuthRegisterRoute
101 | }
102 |
103 | export interface FileRouteTypes {
104 | fileRoutesByFullPath: FileRoutesByFullPath
105 | fullPaths: '/' | '' | '/auth/login' | '/auth/register'
106 | fileRoutesByTo: FileRoutesByTo
107 | to: '/' | '' | '/auth/login' | '/auth/register'
108 | id: '__root__' | '/' | '/_auth' | '/auth/login' | '/auth/register'
109 | fileRoutesById: FileRoutesById
110 | }
111 |
112 | export interface RootRouteChildren {
113 | IndexRoute: typeof IndexRoute
114 | AuthRoute: typeof AuthRoute
115 | AuthLoginRoute: typeof AuthLoginRoute
116 | AuthRegisterRoute: typeof AuthRegisterRoute
117 | }
118 |
119 | const rootRouteChildren: RootRouteChildren = {
120 | IndexRoute: IndexRoute,
121 | AuthRoute: AuthRoute,
122 | AuthLoginRoute: AuthLoginRoute,
123 | AuthRegisterRoute: AuthRegisterRoute,
124 | }
125 |
126 | export const routeTree = rootRoute
127 | ._addFileChildren(rootRouteChildren)
128 | ._addFileTypes()
129 |
130 | /* prettier-ignore-end */
131 |
132 | /* ROUTE_MANIFEST_START
133 | {
134 | "routes": {
135 | "__root__": {
136 | "filePath": "__root.tsx",
137 | "children": [
138 | "/",
139 | "/_auth",
140 | "/auth/login",
141 | "/auth/register"
142 | ]
143 | },
144 | "/": {
145 | "filePath": "index.tsx"
146 | },
147 | "/_auth": {
148 | "filePath": "_auth.tsx"
149 | },
150 | "/auth/login": {
151 | "filePath": "auth/login.tsx"
152 | },
153 | "/auth/register": {
154 | "filePath": "auth/register.tsx"
155 | }
156 | }
157 | }
158 | ROUTE_MANIFEST_END */
159 |
--------------------------------------------------------------------------------
/app/routes/auth/login.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { Card, CardContent, CardFooter } from '@/components/ui/card';
3 | import {
4 | Form,
5 | FormControl,
6 | FormField,
7 | FormItem,
8 | FormLabel,
9 | FormMessage,
10 | } from '@/components/ui/form';
11 | import { Input } from '@/components/ui/input';
12 | import { authSchemas, useSignInMutation } from '@/services/auth';
13 | import { zodResolver } from '@hookform/resolvers/zod';
14 | import { createFileRoute, useRouter } from '@tanstack/react-router';
15 | import { Alert } from '@/components/ui/alert';
16 | import { ArrowRight } from 'lucide-react';
17 | import { useState } from 'react';
18 | import { useForm } from 'react-hook-form';
19 | import { toast } from 'sonner';
20 | import type { z } from 'zod';
21 | import { Link } from '@tanstack/react-router';
22 |
23 | function Login() {
24 | const [error, setError] = useState(null);
25 | const router = useRouter();
26 | const { mutateAsync } = useSignInMutation();
27 | const form = useForm>({
28 | resolver: zodResolver(authSchemas.signIn),
29 | defaultValues: {
30 | email: '',
31 | password: '',
32 | },
33 | });
34 |
35 | const onSubmit = form.handleSubmit(async (data) => {
36 | const signUpPromise = mutateAsync(data, {
37 | onSuccess: () => {
38 | router.navigate({ to: '/' });
39 | },
40 | onError: () => {},
41 | });
42 |
43 | toast.promise(signUpPromise, {
44 | loading: 'Signing in...',
45 | error: 'Failed to sign in',
46 | });
47 |
48 | try {
49 | await signUpPromise;
50 | } catch (error) {
51 | try {
52 | if (!(error instanceof Error)) throw new Error('Unknown error');
53 | const parsedError = JSON.parse(error.message);
54 | setError(parsedError.body.body.message);
55 | } catch (e) {
56 | setError('Failed to sign in');
57 | }
58 | }
59 | });
60 |
61 | return (
62 |
63 |
64 |
65 |
Welcome back
66 |
67 | Sign back into your account
68 |
69 |
70 |
115 |
116 |
122 |
123 |
124 | );
125 | }
126 |
127 | export const Route = createFileRoute('/auth/login')({
128 | component: Login,
129 | });
130 |
--------------------------------------------------------------------------------
/app/routes/auth/register.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { Card, CardContent, CardFooter } from '@/components/ui/card';
3 | import {
4 | Form,
5 | FormControl,
6 | FormField,
7 | FormItem,
8 | FormLabel,
9 | FormMessage,
10 | } from '@/components/ui/form';
11 | import { Input } from '@/components/ui/input';
12 | import { authSchemas, useSignUpMutation } from '@/services/auth';
13 | import { zodResolver } from '@hookform/resolvers/zod';
14 | import { Link } from '@tanstack/react-router';
15 | import { createFileRoute, useRouter } from '@tanstack/react-router';
16 | import { ArrowRight } from 'lucide-react';
17 | import { useForm } from 'react-hook-form';
18 | import { toast } from 'sonner';
19 | import type { z } from 'zod';
20 |
21 | function Register() {
22 | const router = useRouter();
23 | const { mutateAsync } = useSignUpMutation();
24 | const form = useForm>({
25 | resolver: zodResolver(authSchemas.signUp),
26 | defaultValues: {
27 | email: '',
28 | name: '',
29 | password: '',
30 | },
31 | });
32 |
33 | const onSubmit = form.handleSubmit(async (data) => {
34 | const signUpPromise = mutateAsync(data, {
35 | onSuccess: () => {
36 | router.navigate({ to: '/' });
37 | },
38 | });
39 |
40 | toast.promise(signUpPromise, {
41 | loading: 'Creating account...',
42 | success: 'Account created successfully',
43 | error: 'Failed to create account',
44 | });
45 |
46 | await signUpPromise;
47 | });
48 |
49 | return (
50 |
51 |
52 |
53 |
Get started on My app
54 |
55 | The fastest way to kickstart your TanStack application.
56 |
57 |
58 |
117 |
118 |
119 |
125 |
126 |
127 | );
128 | }
129 |
130 | export const Route = createFileRoute('/auth/register')({
131 | component: Register,
132 | });
133 |
--------------------------------------------------------------------------------
/app/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import type * as LabelPrimitive from '@radix-ui/react-label';
3 | import { Slot } from '@radix-ui/react-slot';
4 | import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
5 | import { Controller, FormProvider, useFormContext } from 'react-hook-form';
6 |
7 | import { cn } from '@/lib/utils';
8 | import { Label } from '@/components/ui/label';
9 |
10 | const Form = FormProvider;
11 |
12 | type FormFieldContextValue<
13 | TFieldValues extends FieldValues = FieldValues,
14 | TName extends FieldPath = FieldPath,
15 | > = {
16 | name: TName;
17 | };
18 |
19 | const FormFieldContext = React.createContext(
20 | {} as FormFieldContextValue,
21 | );
22 |
23 | const FormField = <
24 | TFieldValues extends FieldValues = FieldValues,
25 | TName extends FieldPath = FieldPath,
26 | >({
27 | ...props
28 | }: ControllerProps) => {
29 | return (
30 |
31 |
32 |
33 | );
34 | };
35 |
36 | const useFormField = () => {
37 | const fieldContext = React.useContext(FormFieldContext);
38 | const itemContext = React.useContext(FormItemContext);
39 | const { getFieldState, formState } = useFormContext();
40 |
41 | const fieldState = getFieldState(fieldContext.name, formState);
42 |
43 | if (!fieldContext) {
44 | throw new Error('useFormField should be used within ');
45 | }
46 |
47 | const { id } = itemContext;
48 |
49 | return {
50 | id,
51 | name: fieldContext.name,
52 | formItemId: `${id}-form-item`,
53 | formDescriptionId: `${id}-form-item-description`,
54 | formMessageId: `${id}-form-item-message`,
55 | ...fieldState,
56 | };
57 | };
58 |
59 | type FormItemContextValue = {
60 | id: string;
61 | };
62 |
63 | const FormItemContext = React.createContext(
64 | {} as FormItemContextValue,
65 | );
66 |
67 | const FormItem = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => {
71 | const id = React.useId();
72 |
73 | return (
74 |
75 |
76 |
77 | );
78 | });
79 | FormItem.displayName = 'FormItem';
80 |
81 | const FormLabel = React.forwardRef<
82 | React.ElementRef,
83 | React.ComponentPropsWithoutRef
84 | >(({ className, ...props }, ref) => {
85 | const { error, formItemId } = useFormField();
86 |
87 | return (
88 |
94 | );
95 | });
96 | FormLabel.displayName = 'FormLabel';
97 |
98 | const FormControl = React.forwardRef<
99 | React.ElementRef,
100 | React.ComponentPropsWithoutRef
101 | >(({ ...props }, ref) => {
102 | const { error, formItemId, formDescriptionId, formMessageId } =
103 | useFormField();
104 |
105 | return (
106 |
117 | );
118 | });
119 | FormControl.displayName = 'FormControl';
120 |
121 | const FormDescription = React.forwardRef<
122 | HTMLParagraphElement,
123 | React.HTMLAttributes
124 | >(({ className, ...props }, ref) => {
125 | const { formDescriptionId } = useFormField();
126 |
127 | return (
128 |
134 | );
135 | });
136 | FormDescription.displayName = 'FormDescription';
137 |
138 | const FormMessage = React.forwardRef<
139 | HTMLParagraphElement,
140 | React.HTMLAttributes
141 | >(({ className, children, ...props }, ref) => {
142 | const { error, formMessageId } = useFormField();
143 | const body = error ? String(error?.message) : children;
144 |
145 | if (!body) {
146 | return null;
147 | }
148 |
149 | return (
150 |
156 | {body}
157 |
158 | );
159 | });
160 | FormMessage.displayName = 'FormMessage';
161 |
162 | export {
163 | useFormField,
164 | Form,
165 | FormItem,
166 | FormLabel,
167 | FormControl,
168 | FormDescription,
169 | FormMessage,
170 | FormField,
171 | };
172 |
--------------------------------------------------------------------------------
/i18next-parser.config.ts:
--------------------------------------------------------------------------------
1 | // i18next-parser.config.js
2 |
3 | export default {
4 | contextSeparator: '_',
5 | // Key separator used in your translation keys
6 |
7 | createOldCatalogs: true,
8 | // Save the \_old files
9 |
10 | defaultNamespace: 'translation',
11 | // Default namespace used in your i18next config
12 |
13 | defaultValue: '',
14 | // Default value to give to keys with no value
15 | // You may also specify a function accepting the locale, namespace, key, and value as arguments
16 |
17 | indentation: 2,
18 | // Indentation of the catalog files
19 |
20 | keepRemoved: true,
21 | // Keep keys from the catalog that are no longer in code
22 | // You may either specify a boolean to keep or discard all removed keys.
23 | // You may also specify an array of patterns: the keys from the catalog that are no long in the code but match one of the patterns will be kept.
24 | // The patterns are applied to the full key including the namespace, the parent keys and the separators.
25 |
26 | keySeparator: '.',
27 | // Key separator used in your translation keys
28 | // If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
29 |
30 | // see below for more details
31 | lexers: {
32 | hbs: ['HandlebarsLexer'],
33 | handlebars: ['HandlebarsLexer'],
34 |
35 | htm: ['HTMLLexer'],
36 | html: ['HTMLLexer'],
37 |
38 | mjs: ['JavascriptLexer'],
39 | js: ['JavascriptLexer'], // if you're writing jsx inside .js files, change this to JsxLexer
40 | ts: ['JavascriptLexer'],
41 | jsx: ['JsxLexer'],
42 | tsx: ['JsxLexer'],
43 |
44 | default: ['JavascriptLexer'],
45 | },
46 |
47 | lineEnding: 'auto',
48 | // Control the line ending. See options at https://github.com/ryanve/eol
49 |
50 | locales: ['en', 'fr'],
51 | // An array of the locales in your applications
52 |
53 | namespaceSeparator: ':',
54 | // Namespace separator used in your translation keys
55 | // If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
56 |
57 | output: 'app/locales/$LOCALE/$NAMESPACE.json',
58 | // Supports $LOCALE and $NAMESPACE injection
59 | // Supports JSON (.json) and YAML (.yml) file formats
60 | // Where to write the locale files relative to process.cwd()
61 |
62 | pluralSeparator: '_',
63 | // Plural separator used in your translation keys
64 | // If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys.
65 | // If you don't want to generate keys for plurals (for example, in case you are using ICU format), set `pluralSeparator: false`.
66 |
67 | input: 'app/**/*.{ts,tsx}',
68 | // An array of globs that describe where to look for source files
69 | // relative to the location of the configuration file
70 |
71 | sort: false,
72 | // Whether or not to sort the catalog. Can also be a [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters)
73 |
74 | verbose: false,
75 | // Display info about the parsing including some stats
76 |
77 | failOnWarnings: false,
78 | // Exit with an exit code of 1 on warnings
79 |
80 | failOnUpdate: false,
81 | // Exit with an exit code of 1 when translations are updated (for CI purpose)
82 |
83 | customValueTemplate: null,
84 | // If you wish to customize the value output the value as an object, you can set your own format.
85 | //
86 | // - ${defaultValue} is the default value you set in your translation function.
87 | // - ${filePaths} will be expanded to an array that contains the absolute
88 | // file paths where the translations originated in, in case e.g., you need
89 | // to provide translators with context
90 | //
91 | // Any other custom property will be automatically extracted from the 2nd
92 | // argument of your `t()` function or tOptions in
93 | //
94 | // Example:
95 | // For `t('my-key', {maxLength: 150, defaultValue: 'Hello'})` in
96 | // /path/to/your/file.js,
97 | //
98 | // Using the following customValueTemplate:
99 | //
100 | // customValueTemplate: {
101 | // message: "${defaultValue}",
102 | // description: "${maxLength}",
103 | // paths: "${filePaths}",
104 | // }
105 | //
106 | // Will result in the following item being extracted:
107 | //
108 | // "my-key": {
109 | // "message": "Hello",
110 | // "description": 150,
111 | // "paths": ["/path/to/your/file.js"]
112 | // }
113 |
114 | resetDefaultValueLocale: true,
115 | // The locale to compare with default values to determine whether a default value has been changed.
116 | // If this is set and a default value differs from a translation in the specified locale, all entries
117 | // for that key across locales are reset to the default value, and existing translations are moved to
118 | // the `_old` file.
119 |
120 | i18nextOptions: {
121 | saveMissing: true,
122 | },
123 |
124 | // If you wish to customize options in internally used i18next instance, you can define an object with any
125 | // configuration property supported by i18next (https://www.i18next.com/overview/configuration-options).
126 | // { compatibilityJSON: 'v3' } can be used to generate v3 compatible plurals.
127 |
128 | yamlOptions: null,
129 | // If you wish to customize options for yaml output, you can define an object here.
130 | // Configuration options are here (https://github.com/nodeca/js-yaml#dump-object---options-).
131 | // Example:
132 | // {
133 | // lineWidth: -1,
134 | // }
135 | };
136 |
--------------------------------------------------------------------------------
/app/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
3 | import { Check, ChevronRight, Circle } from 'lucide-react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const DropdownMenu = DropdownMenuPrimitive.Root;
8 |
9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
10 |
11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
12 |
13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
14 |
15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
16 |
17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
18 |
19 | const DropdownMenuSubTrigger = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef & {
22 | inset?: boolean;
23 | }
24 | >(({ className, inset, children, ...props }, ref) => (
25 |
34 | {children}
35 |
36 |
37 | ));
38 | DropdownMenuSubTrigger.displayName =
39 | DropdownMenuPrimitive.SubTrigger.displayName;
40 |
41 | const DropdownMenuSubContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, ...props }, ref) => (
45 |
53 | ));
54 | DropdownMenuSubContent.displayName =
55 | DropdownMenuPrimitive.SubContent.displayName;
56 |
57 | const DropdownMenuContent = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, sideOffset = 4, ...props }, ref) => (
61 |
62 |
71 |
72 | ));
73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
74 |
75 | const DropdownMenuItem = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef & {
78 | inset?: boolean;
79 | }
80 | >(({ className, inset, ...props }, ref) => (
81 |
90 | ));
91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
92 |
93 | const DropdownMenuCheckboxItem = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, children, checked, ...props }, ref) => (
97 |
106 |
107 |
108 |
109 |
110 |
111 | {children}
112 |
113 | ));
114 | DropdownMenuCheckboxItem.displayName =
115 | DropdownMenuPrimitive.CheckboxItem.displayName;
116 |
117 | const DropdownMenuRadioItem = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, children, ...props }, ref) => (
121 |
129 |
130 |
131 |
132 |
133 |
134 | {children}
135 |
136 | ));
137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
138 |
139 | const DropdownMenuLabel = React.forwardRef<
140 | React.ElementRef,
141 | React.ComponentPropsWithoutRef & {
142 | inset?: boolean;
143 | }
144 | >(({ className, inset, ...props }, ref) => (
145 |
154 | ));
155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
156 |
157 | const DropdownMenuSeparator = React.forwardRef<
158 | React.ElementRef,
159 | React.ComponentPropsWithoutRef
160 | >(({ className, ...props }, ref) => (
161 |
166 | ));
167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
168 |
169 | const DropdownMenuShortcut = ({
170 | className,
171 | ...props
172 | }: React.HTMLAttributes) => {
173 | return (
174 |
178 | );
179 | };
180 | DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
181 |
182 | export {
183 | DropdownMenu,
184 | DropdownMenuTrigger,
185 | DropdownMenuContent,
186 | DropdownMenuItem,
187 | DropdownMenuCheckboxItem,
188 | DropdownMenuRadioItem,
189 | DropdownMenuLabel,
190 | DropdownMenuSeparator,
191 | DropdownMenuShortcut,
192 | DropdownMenuGroup,
193 | DropdownMenuPortal,
194 | DropdownMenuSub,
195 | DropdownMenuSubContent,
196 | DropdownMenuSubTrigger,
197 | DropdownMenuRadioGroup,
198 | };
199 |
--------------------------------------------------------------------------------
/app/components/theme/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | /*
4 | This file is adapted from next-themes to work with tanstack start.
5 | next-themes can be found at https://github.com/pacocoursey/next-themes under the MIT license.
6 | */
7 |
8 | import * as React from 'react';
9 | import { default as ThemeScriptContent } from './ThemeScript';
10 |
11 | interface ValueObject {
12 | [themeName: string]: string;
13 | }
14 |
15 | export interface UseThemeProps {
16 | /** List of all available theme names */
17 | themes: string[];
18 | /** Forced theme name for the current page */
19 | forcedTheme?: string | undefined;
20 | /** Update the theme */
21 | setTheme: React.Dispatch>;
22 | /** Active theme name */
23 | theme?: string | undefined;
24 | /** If `enableSystem` is true and the active theme is "system", this returns whether the system preference resolved to "dark" or "light". Otherwise, identical to `theme` */
25 | resolvedTheme?: string | undefined;
26 | /** If enableSystem is true, returns the System theme preference ("dark" or "light"), regardless what the active theme is */
27 | systemTheme?: 'dark' | 'light' | undefined;
28 | }
29 |
30 | export type Attribute = `data-${string}` | 'class';
31 |
32 | export interface ThemeProviderProps extends React.PropsWithChildren {
33 | /** List of all available theme names */
34 | themes?: string[] | undefined;
35 | /** Forced theme name for the current page */
36 | forcedTheme?: string | undefined;
37 | /** Whether to switch between dark and light themes based on prefers-color-scheme */
38 | enableSystem?: boolean | undefined;
39 | /** Disable all CSS transitions when switching themes */
40 | disableTransitionOnChange?: boolean | undefined;
41 | /** Whether to indicate to browsers which color scheme is used (dark or light) for built-in UI like inputs and buttons */
42 | enableColorScheme?: boolean | undefined;
43 | /** Key used to store theme setting in localStorage */
44 | storageKey?: string | undefined;
45 | /** Default theme name (for v0.0.12 and lower the default was light). If `enableSystem` is false, the default theme is light */
46 | defaultTheme?: string | undefined;
47 | /** HTML attribute modified based on the active theme. Accepts `class`, `data-*` (meaning any data attribute, `data-mode`, `data-color`, etc.), or an array which could include both */
48 | attribute?: Attribute | Attribute[] | undefined;
49 | /** Mapping of theme name to HTML attribute value. Object where key is the theme name and value is the attribute value */
50 | value?: ValueObject | undefined;
51 | /** Nonce string to pass to the inline script for CSP headers */
52 | nonce?: string | undefined;
53 | }
54 |
55 | const colorSchemes = ['light', 'dark'];
56 | const MEDIA = '(prefers-color-scheme: dark)';
57 | const isServer = typeof window === 'undefined';
58 | const ThemeContext = React.createContext(undefined);
59 | const defaultContext: UseThemeProps = { setTheme: () => {}, themes: [] };
60 |
61 | export const useTheme = () => React.useContext(ThemeContext) ?? defaultContext;
62 |
63 | export const ThemeProvider = (props: ThemeProviderProps): React.ReactNode => {
64 | const context = React.useContext(ThemeContext);
65 |
66 | // Ignore nested context providers, just passthrough children
67 | if (context) return props.children;
68 | return ;
69 | };
70 |
71 | const defaultThemes = ['light', 'dark'];
72 |
73 | const Theme = ({
74 | forcedTheme,
75 | disableTransitionOnChange = false,
76 | enableSystem = true,
77 | enableColorScheme = true,
78 | storageKey = 'theme',
79 | themes = defaultThemes,
80 | defaultTheme = enableSystem ? 'system' : 'light',
81 | attribute = 'data-theme',
82 | value,
83 | children,
84 | nonce,
85 | }: ThemeProviderProps) => {
86 | const [theme, setThemeState] = React.useState(() =>
87 | getTheme(storageKey, defaultTheme),
88 | );
89 | const [resolvedTheme, setResolvedTheme] = React.useState(() =>
90 | getTheme(storageKey),
91 | );
92 | const attrs = !value ? themes : Object.values(value);
93 |
94 | // biome-ignore lint/correctness/useExhaustiveDependencies:
95 | const applyTheme = React.useCallback((theme: string | undefined) => {
96 | let resolved = theme;
97 | if (!resolved) return;
98 |
99 | // If theme is system, resolve it before setting theme
100 | if (theme === 'system' && enableSystem) {
101 | resolved = getSystemTheme();
102 | }
103 |
104 | const name = value ? value[resolved] : resolved;
105 | const enable = disableTransitionOnChange ? disableAnimation() : null;
106 | const d = document.documentElement;
107 |
108 | const handleAttribute = (attr: Attribute) => {
109 | if (attr === 'class') {
110 | d.classList.remove(...attrs);
111 | if (name) d.classList.add(name);
112 | } else if (attr.startsWith('data-')) {
113 | if (name) {
114 | d.setAttribute(attr, name);
115 | } else {
116 | d.removeAttribute(attr);
117 | }
118 | }
119 | };
120 |
121 | if (Array.isArray(attribute)) attribute.forEach(handleAttribute);
122 | else handleAttribute(attribute);
123 |
124 | if (enableColorScheme) {
125 | const fallback = colorSchemes.includes(defaultTheme)
126 | ? defaultTheme
127 | : null;
128 | const colorScheme = colorSchemes.includes(resolved) ? resolved : fallback;
129 | d.style.colorScheme = colorScheme ?? 'dark';
130 | }
131 |
132 | enable?.();
133 | // eslint-disable-next-line react-hooks/exhaustive-deps
134 | }, []);
135 |
136 | const setTheme = React.useCallback(
137 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
138 | (value: any) => {
139 | const newTheme = typeof value === 'function' ? value(theme) : value;
140 | setThemeState(newTheme);
141 |
142 | // Save to storage
143 | try {
144 | localStorage.setItem(storageKey, newTheme);
145 | } catch (e) {
146 | // Unsupported
147 | }
148 | },
149 | // eslint-disable-next-line react-hooks/exhaustive-deps
150 | [theme],
151 | );
152 |
153 | const [, startTransition] = React.useTransition();
154 |
155 | // biome-ignore lint/correctness/useExhaustiveDependencies:
156 | const handleMediaQuery = React.useCallback(
157 | (e: MediaQueryListEvent | MediaQueryList) => {
158 | const resolved = getSystemTheme(e);
159 | startTransition(() => {
160 | setResolvedTheme(resolved);
161 | });
162 |
163 | if (theme === 'system' && enableSystem && !forcedTheme) {
164 | applyTheme('system');
165 | }
166 | },
167 | // eslint-disable-next-line react-hooks/exhaustive-deps
168 | [theme, forcedTheme],
169 | );
170 |
171 | // Always listen to System preference
172 | React.useEffect(() => {
173 | const media = window.matchMedia(MEDIA);
174 |
175 | // Intentionally use deprecated listener methods to support iOS & old browsers
176 | media.addListener(handleMediaQuery);
177 | handleMediaQuery(media);
178 |
179 | return () => media.removeListener(handleMediaQuery);
180 | }, [handleMediaQuery]);
181 |
182 | // localStorage event handling
183 | // biome-ignore lint/correctness/useExhaustiveDependencies:
184 | React.useEffect(() => {
185 | const handleStorage = (e: StorageEvent) => {
186 | if (e.key !== storageKey) {
187 | return;
188 | }
189 |
190 | // If default theme set, use it if localstorage === null (happens on local storage manual deletion)
191 | const theme = e.newValue || defaultTheme;
192 | setTheme(theme);
193 | };
194 |
195 | window.addEventListener('storage', handleStorage);
196 | return () => window.removeEventListener('storage', handleStorage);
197 | // eslint-disable-next-line react-hooks/exhaustive-deps
198 | }, [setTheme]);
199 |
200 | // Whenever theme or forcedTheme changes, apply it
201 | // biome-ignore lint/correctness/useExhaustiveDependencies:
202 | React.useEffect(() => {
203 | applyTheme(forcedTheme ?? theme);
204 | // eslint-disable-next-line react-hooks/exhaustive-deps
205 | }, [forcedTheme, theme]);
206 |
207 | const providerValue = React.useMemo(
208 | () => ({
209 | theme,
210 | setTheme,
211 | forcedTheme,
212 | resolvedTheme: theme === 'system' ? resolvedTheme : theme,
213 | themes: enableSystem ? [...themes, 'system'] : themes,
214 | systemTheme: (enableSystem ? resolvedTheme : undefined) as
215 | | 'light'
216 | | 'dark'
217 | | undefined,
218 | }),
219 | [theme, setTheme, forcedTheme, resolvedTheme, enableSystem, themes],
220 | );
221 |
222 | return (
223 |
224 |
237 | {children}
238 |
239 | );
240 | };
241 |
242 | const ThemeScript = React.memo(
243 | ({
244 | forcedTheme,
245 | storageKey,
246 | attribute,
247 | enableSystem,
248 | enableColorScheme,
249 | defaultTheme,
250 | value,
251 | themes,
252 | nonce,
253 | }: Omit & { defaultTheme: string }) => {
254 | const scriptArgs = JSON.stringify([
255 | attribute,
256 | storageKey,
257 | defaultTheme,
258 | forcedTheme,
259 | themes,
260 | value,
261 | enableSystem,
262 | enableColorScheme,
263 | ]).slice(1, -1);
264 |
265 | return (
266 |
274 | // <>>
275 | );
276 | },
277 | );
278 |
279 | ThemeScript.displayName = 'ThemeScript';
280 |
281 | // Helpers
282 | const getTheme = (key: string, fallback?: string) => {
283 | if (isServer) return undefined;
284 | let theme: string | undefined;
285 | try {
286 | theme = localStorage.getItem(key) || undefined;
287 | } catch (e) {
288 | // Unsupported
289 | }
290 | return theme || fallback;
291 | };
292 |
293 | const disableAnimation = () => {
294 | const css = document.createElement('style');
295 | css.appendChild(
296 | document.createTextNode(
297 | '*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}',
298 | ),
299 | );
300 | document.head.appendChild(css);
301 |
302 | return () => {
303 | // Force restyle
304 | (() => window.getComputedStyle(document.body))();
305 |
306 | // Wait for next tick before removing
307 | setTimeout(() => {
308 | document.head.removeChild(css);
309 | }, 1);
310 | };
311 | };
312 |
313 | const getSystemTheme = (e?: MediaQueryList | MediaQueryListEvent) => {
314 | const event = e ?? window.matchMedia(MEDIA);
315 | const isDark = event.matches;
316 | const systemTheme = isDark ? 'dark' : 'light';
317 | return systemTheme;
318 | };
319 |
--------------------------------------------------------------------------------