diff --git a/.oxlintrc.json b/.oxlintrc.json
index cf6e13a..f907117 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -27,6 +27,9 @@
"import"
],
"rules": {
+ // Correctness
+ "react/no-children-prop": ["off"],
+
// Perf
"no-await-in-loop": "error",
"no-useless-call": "error",
diff --git a/messages/en.json b/messages/en.json
index 4e2a872..8128340 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1,5 +1,9 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"app_name": "Devsantara Kit",
- "app_description": "The blueprint for your next big idea"
+ "app_description": "The blueprint for your next big idea",
+
+ "common_error_something_went_wrong": "Something went wrong",
+ "common_error_form_validation_title": "There is something wrong with the form",
+ "common_error_form_validation_description": "Please review the form and correct them to continue."
}
diff --git a/messages/id.json b/messages/id.json
index 3f79448..e8ec8b5 100644
--- a/messages/id.json
+++ b/messages/id.json
@@ -1,5 +1,9 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"app_name": "Devsantara Kit",
- "app_description": "Rancangan dasar untuk ide besar kamu berikutnya"
+ "app_description": "Rancangan dasar untuk ide besar kamu berikutnya",
+
+ "common_error_something_went_wrong": "Terjadi kesalahan",
+ "common_error_form_validation_title": "Ada kesalahan pada formulir",
+ "common_error_form_validation_description": "Silakan periksa kembali formulir dan perbaiki untuk melanjutkan."
}
diff --git a/messages/zh-CN.json b/messages/zh-CN.json
index 095667c..b681e84 100644
--- a/messages/zh-CN.json
+++ b/messages/zh-CN.json
@@ -1,5 +1,9 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"app_name": "Devsantara 工具包",
- "app_description": "您下一个伟大构想的基石计划"
+ "app_description": "您下一个伟大构想的基石计划",
+
+ "common_error_something_went_wrong": "出现了一些问题",
+ "common_error_form_validation_title": "表单存在问题",
+ "common_error_form_validation_description": "请检查表单并更正后继续。"
}
diff --git a/package.json b/package.json
index 7019869..904e75c 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,9 @@
"@posthog/react": "^1.5.2",
"@t3-oss/env-core": "^0.13.10",
"@tailwindcss/vite": "^4.1.18",
+ "@tanstack/react-form": "^1.27.7",
+ "@tanstack/react-form-devtools": "^0.2.12",
+ "@tanstack/react-form-start": "^1.27.7",
"@tanstack/react-router": "^1.144.0",
"@tanstack/react-start": "^1.145.5",
"class-variance-authority": "^0.7.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 19416bb..6791c98 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -27,6 +27,15 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ '@tanstack/react-form':
+ specifier: ^1.27.7
+ version: 1.27.7(@tanstack/react-start@1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@tanstack/react-form-devtools':
+ specifier: ^0.2.12
+ version: 0.2.12(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)
+ '@tanstack/react-form-start':
+ specifier: ^1.27.7
+ version: 1.27.7(@tanstack/react-start@1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@tanstack/react-router':
specifier: ^1.144.0
version: 1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -3090,6 +3099,27 @@ packages:
peerDependencies:
solid-js: '>=1.9.7'
+ '@tanstack/devtools-utils@0.3.0':
+ resolution: {integrity: sha512-JgApXVrgtgSLIPrm/QWHx0u6c9Ji0MNMDWhwujapj8eMzux5aOfi+2Ycwzj0A0qITXA12SEPYV3HC568mDtYmQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/react': '>=17.0.0'
+ preact: '>=10.0.0'
+ react: '>=17.0.0'
+ solid-js: '>=1.9.7'
+ vue: '>=3.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ preact:
+ optional: true
+ react:
+ optional: true
+ solid-js:
+ optional: true
+ vue:
+ optional: true
+
'@tanstack/devtools-vite@0.4.0':
resolution: {integrity: sha512-vZ5SsjcLSLC+lBb4N6QDJEdrsrORs8OtIcwQafexAR7aJOv6SxGNoqERujEbTzfWY+PAypa1oYxPqtEAOcitDw==}
engines: {node: '>=18'}
@@ -3102,10 +3132,22 @@ packages:
peerDependencies:
solid-js: '>=1.9.7'
+ '@tanstack/form-core@1.27.7':
+ resolution: {integrity: sha512-nvogpyE98fhb0NDw1Bf2YaCH+L7ZIUgEpqO9TkHucDn6zg3ni521boUpv0i8HKIrmmFwDYjWZoCnrgY4HYWTkw==}
+
+ '@tanstack/form-devtools@0.2.12':
+ resolution: {integrity: sha512-+X4i4aKszU04G5ID3Q/lslKpmop6QfV9To8MdEzEGGGBakKPtilFzKq+xSpcqd/DPtq2+LtbCSZWQP9CJhInnA==}
+ peerDependencies:
+ solid-js: '>=1.9.9'
+
'@tanstack/history@1.141.0':
resolution: {integrity: sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ==}
engines: {node: '>=12'}
+ '@tanstack/pacer-lite@0.1.1':
+ resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==}
+ engines: {node: '>=18'}
+
'@tanstack/react-devtools@0.9.0':
resolution: {integrity: sha512-Lq0svXOTG5N61SHgx8F0on6zz2GB0kmFjN/yyfNLrJyRgJ+U3jYFRd9ti3uBPABsXzHQMHYYujnTXrOYp/OaUg==}
engines: {node: '>=18'}
@@ -3115,6 +3157,29 @@ packages:
react: '>=16.8'
react-dom: '>=16.8'
+ '@tanstack/react-form-devtools@0.2.12':
+ resolution: {integrity: sha512-6m95ZKJyfER5mUp7DR7/FtsDoVmgHS8NgOkh3Z/pr1tGEnomK+HULuZZJd7lfT3r9tCDuC4rjPNZYLpzq3kdxA==}
+ peerDependencies:
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@tanstack/react-form-start@1.27.7':
+ resolution: {integrity: sha512-9JTxhCTCnrYK35xaJ78KUhq7Ek9SXyuErQ3WMDJtzc31XZtAEDkIigh/aSEtL+lkc/5ZDgo20jXxLbdtsfcotA==}
+ peerDependencies:
+ '@tanstack/react-start': ^1.134.9
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@tanstack/react-start':
+ optional: true
+
+ '@tanstack/react-form@1.27.7':
+ resolution: {integrity: sha512-xTg4qrUY0fuLaSnkATLZcK3BWlnwLp7IuAb6UTbZKngiDEvvDCNTvVvHgPlgef1O2qN4klZxInRyRY6oEkXZ2A==}
+ peerDependencies:
+ '@tanstack/react-start': '*'
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@tanstack/react-start':
+ optional: true
+
'@tanstack/react-router-devtools@1.144.0':
resolution: {integrity: sha512-nstjZvZbOM4U0/Hzi82rtsP1DsR2tfigBidK+WuaDRVVstBsnwVor3DQXTGY5CcfgIiMI3eKzI17VOy3SQDDoQ==}
engines: {node: '>=12'}
@@ -3228,6 +3293,9 @@ packages:
resolution: {integrity: sha512-DuUx5CXfLNettyJlsHDQp66y5haeqzXJkUor7kp5p10SVv24p76dTYqBOpw+wQz//RfJlOciIZFVBcKezXXY0w==}
engines: {node: '>=22.12.0'}
+ '@tanstack/store@0.7.7':
+ resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==}
+
'@tanstack/store@0.8.0':
resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==}
@@ -3899,6 +3967,9 @@ packages:
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
+ dayjs@1.11.19:
+ resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -3911,6 +3982,9 @@ packages:
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+ decode-formdata@0.9.0:
+ resolution: {integrity: sha512-q5uwOjR3Um5YD+ZWPOF/1sGHVW9A5rCrRwITQChRXlmPkxDFBqCm4jNTIVdGHNH9OnR+V9MoZVgRhsFb+ARbUw==}
+
dedent@1.5.1:
resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==}
peerDependencies:
@@ -3963,6 +4037,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+ devalue@5.6.2:
+ resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==}
+
diff@8.0.2:
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
engines: {node: '>=0.3.1'}
@@ -9053,6 +9130,17 @@ snapshots:
transitivePeerDependencies:
- csstype
+ '@tanstack/devtools-utils@0.3.0(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)':
+ dependencies:
+ '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.10)
+ optionalDependencies:
+ '@types/react': 19.2.7
+ preact: 10.28.1
+ react: 19.2.3
+ solid-js: 1.9.10
+ transitivePeerDependencies:
+ - csstype
+
'@tanstack/devtools-vite@0.4.0(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@babel/core': 7.28.5
@@ -9087,8 +9175,32 @@ snapshots:
- csstype
- utf-8-validate
+ '@tanstack/form-core@1.27.7':
+ dependencies:
+ '@tanstack/devtools-event-client': 0.4.0
+ '@tanstack/pacer-lite': 0.1.1
+ '@tanstack/store': 0.7.7
+
+ '@tanstack/form-devtools@0.2.12(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)':
+ dependencies:
+ '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.10)
+ '@tanstack/devtools-utils': 0.3.0(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)
+ '@tanstack/form-core': 1.27.7
+ clsx: 2.1.1
+ dayjs: 1.11.19
+ goober: 2.1.18(csstype@3.2.3)
+ solid-js: 1.9.10
+ transitivePeerDependencies:
+ - '@types/react'
+ - csstype
+ - preact
+ - react
+ - vue
+
'@tanstack/history@1.141.0': {}
+ '@tanstack/pacer-lite@0.1.1': {}
+
'@tanstack/react-devtools@0.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)':
dependencies:
'@tanstack/devtools': 0.10.1(csstype@3.2.3)(solid-js@1.9.10)
@@ -9102,6 +9214,39 @@ snapshots:
- solid-js
- utf-8-validate
+ '@tanstack/react-form-devtools@0.2.12(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)':
+ dependencies:
+ '@tanstack/devtools-utils': 0.3.0(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)
+ '@tanstack/form-devtools': 0.2.12(@types/react@19.2.7)(csstype@3.2.3)(preact@10.28.1)(react@19.2.3)(solid-js@1.9.10)
+ react: 19.2.3
+ transitivePeerDependencies:
+ - '@types/react'
+ - csstype
+ - preact
+ - solid-js
+ - vue
+
+ '@tanstack/react-form-start@1.27.7(@tanstack/react-start@1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@tanstack/react-form': 1.27.7(@tanstack/react-start@1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ decode-formdata: 0.9.0
+ devalue: 5.6.2
+ react: 19.2.3
+ optionalDependencies:
+ '@tanstack/react-start': 1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ transitivePeerDependencies:
+ - react-dom
+
+ '@tanstack/react-form@1.27.7(@tanstack/react-start@1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@tanstack/form-core': 1.27.7
+ '@tanstack/react-store': 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@tanstack/react-start': 1.145.5(crossws@0.4.1(srvx@0.10.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ transitivePeerDependencies:
+ - react-dom
+
'@tanstack/react-router-devtools@1.144.0(@tanstack/react-router@1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.144.0)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)':
dependencies:
'@tanstack/react-router': 1.144.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -9299,6 +9444,8 @@ snapshots:
dependencies:
'@tanstack/router-core': 1.144.0
+ '@tanstack/store@0.7.7': {}
+
'@tanstack/store@0.8.0': {}
'@tanstack/virtual-file-routes@1.145.4': {}
@@ -10005,12 +10152,16 @@ snapshots:
date-fns@4.1.0: {}
+ dayjs@1.11.19: {}
+
debug@4.4.3:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
+ decode-formdata@0.9.0: {}
+
dedent@1.5.1: {}
deep-eql@5.0.2: {}
@@ -10047,6 +10198,8 @@ snapshots:
detect-node-es@1.1.0: {}
+ devalue@5.6.2: {}
+
diff@8.0.2: {}
doctrine@3.0.0:
diff --git a/src/devtools/form-devtools.tsx b/src/devtools/form-devtools.tsx
new file mode 100644
index 0000000..6ca2da3
--- /dev/null
+++ b/src/devtools/form-devtools.tsx
@@ -0,0 +1,13 @@
+import type { TanStackDevtoolsReactPlugin } from '@tanstack/react-devtools';
+import { FormDevtoolsPanel } from '@tanstack/react-form-devtools';
+
+/**
+ * Currently this cause error during dependency optimization when using Cloudflare.
+ * @see {@link https://github.com/TanStack/devtools/issues/91}
+ * @see {@link https://github.com/TanStack/devtools/issues/187}
+ * @see {@link https://github.com/TanStack/devtools/issues/289}
+ */
+export const tanstackFormDevtools: TanStackDevtoolsReactPlugin = {
+ name: 'TanStack Form',
+ render: ,
+};
diff --git a/src/devtools/router-devtools.tsx b/src/devtools/router-devtools.tsx
index 36092a8..a682af9 100644
--- a/src/devtools/router-devtools.tsx
+++ b/src/devtools/router-devtools.tsx
@@ -4,4 +4,5 @@ import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
export const tanstackRouterDevtools: TanStackDevtoolsReactPlugin = {
name: 'TanStack Router',
render: ,
+ defaultOpen: true,
};
diff --git a/src/lib/form/components/form-checkbox-multiple.tsx b/src/lib/form/components/form-checkbox-multiple.tsx
new file mode 100644
index 0000000..79e8e01
--- /dev/null
+++ b/src/lib/form/components/form-checkbox-multiple.tsx
@@ -0,0 +1,122 @@
+import { useFieldContext } from '~/lib/form/form.context';
+import { checkIsInvalidField } from '~/lib/form/form.utils';
+import { Checkbox } from '~/ui/components/core/checkbox';
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ FieldTitle,
+ useFieldSet,
+} from '~/ui/components/core/field';
+
+interface Option {
+ label: string;
+ value: string;
+ description?: string;
+ disabled?: boolean;
+}
+
+type Variant = 'default' | 'card';
+
+export function FormCheckboxMultiple({
+ options,
+ variant = 'default',
+ ...props
+}: React.ComponentPropsWithRef & {
+ options: Option[];
+ variant?: Variant;
+}) {
+ return (
+
+ {options.map((option) => {
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function FormCheckboxMultipleItem({
+ variant,
+ option,
+ ...props
+}: React.ComponentPropsWithRef & {
+ option: Option;
+ variant: Variant;
+}) {
+ const field = useFieldContext();
+ const fieldSet = useFieldSet();
+ const isInvalid = checkIsInvalidField(field);
+
+ const id = `${field.name}-${option.value}`;
+ const isDisabled = fieldSet?.disabled || option.disabled;
+
+ function handleCheckedChange(checked: boolean) {
+ if (checked) {
+ field.pushValue(option.value);
+ } else {
+ const index = field.state.value.indexOf(option.value);
+ if (index > -1) {
+ field.removeValue(index);
+ }
+ }
+ }
+
+ const checkbox = (
+
+ );
+
+ const components: Record = {
+ default: (
+
+ {checkbox}
+
+ {option.label}
+ {option.description && (
+ {option.description}
+ )}
+
+
+ ),
+ card: (
+
+
+ {checkbox}
+
+ {option.label}
+ {option.description && (
+ {option.description}
+ )}
+
+
+
+ ),
+ };
+
+ return components[variant];
+}
diff --git a/src/lib/form/components/form-checkbox-single.tsx b/src/lib/form/components/form-checkbox-single.tsx
new file mode 100644
index 0000000..faf0c9a
--- /dev/null
+++ b/src/lib/form/components/form-checkbox-single.tsx
@@ -0,0 +1,105 @@
+import { useFieldContext } from '~/lib/form/form.context';
+import { checkIsInvalidField } from '~/lib/form/form.utils';
+import { Checkbox } from '~/ui/components/core/checkbox';
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ FieldTitle,
+ useFieldSet,
+} from '~/ui/components/core/field';
+
+interface Option {
+ label: string;
+ description?: string;
+}
+
+type Variant = 'default' | 'card';
+
+export function FormCheckboxSingle({
+ option,
+ variant = 'default',
+ ...props
+}: React.ComponentPropsWithRef & {
+ option: Option;
+ variant?: Variant;
+}) {
+ return (
+
+
+
+ );
+}
+
+function FormCheckboxSingleItem({
+ variant,
+ option,
+ disabled,
+ ...props
+}: React.ComponentPropsWithRef & {
+ option: Option;
+ variant: Variant;
+}) {
+ const field = useFieldContext();
+ const fieldSet = useFieldSet();
+ const isInvalid = checkIsInvalidField(field);
+
+ const id = field.name;
+ const isDisabled = fieldSet?.disabled || disabled;
+
+ function handleCheckedChange(checked: boolean) {
+ field.handleChange(checked === true);
+ }
+
+ const checkbox = (
+
+ );
+
+ const components: Record = {
+ default: (
+
+ {checkbox}
+
+ {option.label}
+ {option.description && (
+ {option.description}
+ )}
+
+
+ ),
+ card: (
+
+
+ {checkbox}
+
+ {option.label}
+ {option.description && (
+ {option.description}
+ )}
+
+
+
+ ),
+ };
+
+ return components[variant];
+}
diff --git a/src/lib/form/components/form-error.tsx b/src/lib/form/components/form-error.tsx
new file mode 100644
index 0000000..d3a69b0
--- /dev/null
+++ b/src/lib/form/components/form-error.tsx
@@ -0,0 +1,43 @@
+import { AlertCircleIcon } from 'lucide-react';
+
+import { useFormContext } from '~/lib/form/form.context';
+import { checkIsFormError } from '~/lib/form/form.utils';
+import { m } from '~/lib/i18n/messages';
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+} from '~/ui/components/core/alert';
+
+export function FormError() {
+ const form = useFormContext();
+
+ return (
+ state.errorMap.onSubmit}>
+ {(formSubmitError) => {
+ if (!formSubmitError) return null;
+
+ // On form submission, errors can take two shapes:
+ // 1. Validation errors - an object with field keys mapped to their validation error messages
+ // 2. Form-level errors - manually set errors, typically used when form submission fails
+ const isFormError = checkIsFormError(formSubmitError);
+
+ const title = isFormError
+ ? formSubmitError.title || m.common_error_something_went_wrong()
+ : m.common_error_form_validation_title();
+
+ const message = isFormError
+ ? formSubmitError.message || null
+ : m.common_error_form_validation_description();
+
+ return (
+
+
+ {title}
+ {message && {message}}
+
+ );
+ }}
+
+ );
+}
diff --git a/src/lib/form/components/form-field-error.tsx b/src/lib/form/components/form-field-error.tsx
new file mode 100644
index 0000000..0e396de
--- /dev/null
+++ b/src/lib/form/components/form-field-error.tsx
@@ -0,0 +1,15 @@
+import * as React from 'react';
+
+import { useFieldContext } from '~/lib/form/form.context';
+import { checkIsInvalidField } from '~/lib/form/form.utils';
+import { FieldError } from '~/ui/components/core/field';
+
+export function FormFieldError({
+ ...props
+}: Omit, 'errors'>) {
+ const field = useFieldContext();
+ const isInvalid = checkIsInvalidField(field);
+
+ if (!isInvalid) return null;
+ return ;
+}
diff --git a/src/lib/form/components/form-field-label.tsx b/src/lib/form/components/form-field-label.tsx
new file mode 100644
index 0000000..25409b4
--- /dev/null
+++ b/src/lib/form/components/form-field-label.tsx
@@ -0,0 +1,13 @@
+import * as React from 'react';
+
+import { useFieldContext } from '~/lib/form/form.context';
+import { FieldLabel } from '~/ui/components/core/field';
+
+export function FormFieldLabel({
+ htmlFor,
+ ...props
+}: React.ComponentPropsWithRef) {
+ const field = useFieldContext();
+
+ return ;
+}
diff --git a/src/lib/form/components/form-field.tsx b/src/lib/form/components/form-field.tsx
new file mode 100644
index 0000000..776ab25
--- /dev/null
+++ b/src/lib/form/components/form-field.tsx
@@ -0,0 +1,14 @@
+import * as React from 'react';
+
+import { useFieldContext } from '~/lib/form/form.context';
+import { checkIsInvalidField } from '~/lib/form/form.utils';
+import { Field } from '~/ui/components/core/field';
+
+export function FormField({
+ ...props
+}: React.ComponentPropsWithRef) {
+ const field = useFieldContext();
+ const isInvalid = checkIsInvalidField(field);
+
+ return ;
+}
diff --git a/src/lib/form/components/form-fieldset.tsx b/src/lib/form/components/form-fieldset.tsx
new file mode 100644
index 0000000..3a0950b
--- /dev/null
+++ b/src/lib/form/components/form-fieldset.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react';
+
+import { useFormContext } from '~/lib/form/form.context';
+import { FieldSet } from '~/ui/components/core/field';
+
+export function FormFieldSet({
+ children,
+ disabled,
+ ...props
+}: React.ComponentPropsWithRef) {
+ const form = useFormContext();
+
+ return (
+ state.isSubmitting}>
+ {(isSubmitting) => (
+
+ )}
+
+ );
+}
diff --git a/src/lib/form/components/form-input.tsx b/src/lib/form/components/form-input.tsx
new file mode 100644
index 0000000..73eb030
--- /dev/null
+++ b/src/lib/form/components/form-input.tsx
@@ -0,0 +1,30 @@
+import { useFieldContext } from '~/lib/form/form.context';
+import { checkIsInvalidField } from '~/lib/form/form.utils';
+import { Input } from '~/ui/components/core/input';
+
+export function FormInput({
+ className,
+ ...props
+}: React.ComponentPropsWithRef) {
+ const field = useFieldContext();
+ const isInvalid = checkIsInvalidField(field);
+
+ const id = field.name;
+
+ function handleChange(event: React.ChangeEvent) {
+ field.handleChange(event.target.value);
+ }
+
+ return (
+
+ );
+}
diff --git a/src/lib/form/components/form-radio-group.tsx b/src/lib/form/components/form-radio-group.tsx
new file mode 100644
index 0000000..7acdf66
--- /dev/null
+++ b/src/lib/form/components/form-radio-group.tsx
@@ -0,0 +1,123 @@
+import * as React from 'react';
+
+import { useFieldContext } from '~/lib/form/form.context';
+import { checkIsInvalidField } from '~/lib/form/form.utils';
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldLabel,
+ FieldTitle,
+ useFieldSet,
+} from '~/ui/components/core/field';
+import { RadioGroup, RadioGroupItem } from '~/ui/components/core/radio-group';
+
+interface Option {
+ label: string;
+ value: string;
+ description?: string;
+ disabled?: boolean;
+}
+
+type Variant = 'default' | 'card';
+
+export function FormRadioGroup({
+ options,
+ variant = 'default',
+ ...props
+}: React.ComponentPropsWithRef & {
+ options: Option[];
+ variant?: Variant;
+}) {
+ const field = useFieldContext();
+ const fieldSet = useFieldSet();
+
+ const disabled = fieldSet?.disabled;
+
+ function handleValueChange(value: string) {
+ field.handleChange(value);
+ }
+
+ return (
+
+ {options.map((option) => {
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function FormRadioGroupItem({
+ variant,
+ option,
+ ...props
+}: Omit, 'value'> & {
+ option: Option;
+ variant: Variant;
+}) {
+ const field = useFieldContext();
+ const fieldSet = useFieldSet();
+ const isInvalid = checkIsInvalidField(field);
+
+ const id = `${field.name}-${option.value}`;
+ const isDisabled = fieldSet?.disabled || option.disabled;
+
+ const radioGroup = (
+
+ );
+
+ const components: Record = {
+ default: (
+
+ {radioGroup}
+
+ {option.label}
+ {option.description && (
+ {option.description}
+ )}
+
+
+ ),
+ card: (
+
+
+
+ {option.label}
+ {option.description && (
+ {option.description}
+ )}
+
+ {radioGroup}
+
+
+ ),
+ };
+
+ return components[variant];
+}
diff --git a/src/lib/form/components/form-reset.tsx b/src/lib/form/components/form-reset.tsx
new file mode 100644
index 0000000..40c4ab6
--- /dev/null
+++ b/src/lib/form/components/form-reset.tsx
@@ -0,0 +1,33 @@
+import * as React from 'react';
+
+import { useFormContext } from '~/lib/form/form.context';
+import { Button } from '~/ui/components/core/button';
+
+export function FormReset({
+ children,
+ ...props
+}: React.ComponentPropsWithRef) {
+ const form = useFormContext();
+
+ function handleReset(event: React.MouseEvent) {
+ event.preventDefault();
+ form.reset();
+ }
+
+ return (
+ state.isSubmitting}>
+ {(isSubmitting) => (
+
+ )}
+
+ );
+}
diff --git a/src/lib/form/components/form-root.tsx b/src/lib/form/components/form-root.tsx
new file mode 100644
index 0000000..7488772
--- /dev/null
+++ b/src/lib/form/components/form-root.tsx
@@ -0,0 +1,21 @@
+import * as React from 'react';
+
+import { useFormContext } from '~/lib/form/form.context';
+
+export function FormRoot({
+ children,
+ ...props
+}: React.ComponentPropsWithRef<'form'>) {
+ const form = useFormContext();
+
+ function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ form.handleSubmit();
+ }
+
+ return (
+
+ );
+}
diff --git a/src/lib/form/components/form-select.tsx b/src/lib/form/components/form-select.tsx
new file mode 100644
index 0000000..cce118c
--- /dev/null
+++ b/src/lib/form/components/form-select.tsx
@@ -0,0 +1,73 @@
+import { useFieldContext } from '~/lib/form/form.context';
+import { checkIsInvalidField } from '~/lib/form/form.utils';
+import { useFieldSet } from '~/ui/components/core/field';
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '~/ui/components/core/select';
+import { cn } from '~/ui/utils';
+
+interface Option {
+ label: string;
+ value: string;
+ disabled?: boolean;
+}
+
+export function FormSelect({
+ className,
+ placeholder,
+ options,
+ disabled,
+ ...props
+}: React.ComponentPropsWithRef & {
+ placeholder?: string;
+ options: Option[];
+}) {
+ const field = useFieldContext();
+ const fieldSet = useFieldSet();
+ const isInvalid = checkIsInvalidField(field);
+
+ const id = field.name;
+ const isDisabled = fieldSet?.disabled || disabled;
+
+ function handleValueChange(value: string) {
+ field.handleChange(value);
+ }
+
+ return (
+
+ );
+}
diff --git a/src/lib/form/components/form-submit.tsx b/src/lib/form/components/form-submit.tsx
new file mode 100644
index 0000000..b880ef7
--- /dev/null
+++ b/src/lib/form/components/form-submit.tsx
@@ -0,0 +1,27 @@
+import { Loader2Icon } from 'lucide-react';
+import * as React from 'react';
+
+import { useFormContext } from '~/lib/form/form.context';
+import { Button } from '~/ui/components/core/button';
+
+export function FormSubmit({
+ children,
+ ...props
+}: React.ComponentPropsWithRef) {
+ const form = useFormContext();
+
+ return (
+ state.isSubmitting}>
+ {(isSubmitting) => (
+
+ )}
+
+ );
+}
diff --git a/src/lib/form/components/form-switch.tsx b/src/lib/form/components/form-switch.tsx
new file mode 100644
index 0000000..2cc3173
--- /dev/null
+++ b/src/lib/form/components/form-switch.tsx
@@ -0,0 +1,30 @@
+import { useFieldContext } from '~/lib/form/form.context';
+import { checkIsInvalidField } from '~/lib/form/form.utils';
+import { Switch } from '~/ui/components/core/switch';
+
+export function FormSwitch({
+ className,
+ ...props
+}: React.ComponentPropsWithRef) {
+ const field = useFieldContext();
+ const isInvalid = checkIsInvalidField(field);
+
+ const id = field.name;
+
+ function handleCheckedChange(checked: boolean) {
+ field.handleChange(checked);
+ }
+
+ return (
+
+ );
+}
diff --git a/src/lib/form/components/form-textarea.tsx b/src/lib/form/components/form-textarea.tsx
new file mode 100644
index 0000000..fd542ce
--- /dev/null
+++ b/src/lib/form/components/form-textarea.tsx
@@ -0,0 +1,31 @@
+import { useFieldContext } from '~/lib/form/form.context';
+import { checkIsInvalidField } from '~/lib/form/form.utils';
+import { Textarea } from '~/ui/components/core/textarea';
+import { cn } from '~/ui/utils';
+
+export function FormTextarea({
+ className,
+ ...props
+}: React.ComponentPropsWithRef) {
+ const field = useFieldContext();
+ const isInvalid = checkIsInvalidField(field);
+
+ const id = field.name;
+
+ function handleChange(event: React.ChangeEvent) {
+ field.handleChange(event.target.value);
+ }
+
+ return (
+
+ );
+}
diff --git a/src/lib/form/form.context.tsx b/src/lib/form/form.context.tsx
new file mode 100644
index 0000000..a6705ea
--- /dev/null
+++ b/src/lib/form/form.context.tsx
@@ -0,0 +1,4 @@
+import { createFormHookContexts } from '@tanstack/react-form';
+
+export const { fieldContext, formContext, useFieldContext, useFormContext } =
+ createFormHookContexts();
diff --git a/src/lib/form/form.utils.ts b/src/lib/form/form.utils.ts
new file mode 100644
index 0000000..2fa0672
--- /dev/null
+++ b/src/lib/form/form.utils.ts
@@ -0,0 +1,51 @@
+import type { AnyFieldApi } from '@tanstack/react-form';
+
+/**
+ * Symbol used to identify form-level errors.
+ * Provides type-safe discrimination for error objects.
+ */
+export const FormErrorSymbol = Symbol('FORM_ERROR');
+
+/** Represents a form-level error object set when form submission fails (e.g error from server). */
+export type FormError = ReturnType;
+
+/** Checks if a field has been touched by the user and contains invalid data. */
+export function checkIsInvalidField(field: AnyFieldApi) {
+ return field.state.meta.isTouched && !field.state.meta.isValid;
+}
+
+/**
+ * Creates a field-level error object with a message.
+ * Used for validation errors on individual form fields.
+ */
+export function createFieldError(message: string) {
+ return { message } as const;
+}
+
+/**
+ * Creates a form-level error object with optional title and message.
+ * Form-level errors are typically used for cross-field validation
+ * or errors from server submissions.
+ */
+export function createFormError({
+ title,
+ message,
+}: {
+ title?: string;
+ message?: string;
+}) {
+ return { _tag: FormErrorSymbol, title, message } as const;
+}
+
+/**
+ * Type guard to check if a value is a FormError object.
+ * Safely narrows the type for error handling.
+ */
+export function checkIsFormError(errors: unknown): errors is FormError {
+ return (
+ typeof errors === 'object' &&
+ errors !== null &&
+ '_tag' in errors &&
+ errors?._tag === FormErrorSymbol
+ );
+}
diff --git a/src/lib/form/index.tsx b/src/lib/form/index.tsx
new file mode 100644
index 0000000..68858a2
--- /dev/null
+++ b/src/lib/form/index.tsx
@@ -0,0 +1,68 @@
+import { createFormHook } from '@tanstack/react-form';
+
+import { FormCheckboxMultiple } from '~/lib/form/components/form-checkbox-multiple';
+import { FormCheckboxSingle } from '~/lib/form/components/form-checkbox-single';
+import { FormError } from '~/lib/form/components/form-error';
+import { FormField } from '~/lib/form/components/form-field';
+import { FormFieldError } from '~/lib/form/components/form-field-error';
+import { FormFieldLabel } from '~/lib/form/components/form-field-label';
+import { FormFieldSet } from '~/lib/form/components/form-fieldset';
+import { FormInput } from '~/lib/form/components/form-input';
+import { FormRadioGroup } from '~/lib/form/components/form-radio-group';
+import { FormReset } from '~/lib/form/components/form-reset';
+import { FormRoot } from '~/lib/form/components/form-root';
+import { FormSelect } from '~/lib/form/components/form-select';
+import { FormSubmit } from '~/lib/form/components/form-submit';
+import { FormSwitch } from '~/lib/form/components/form-switch';
+import { FormTextarea } from '~/lib/form/components/form-textarea';
+import { fieldContext, formContext } from '~/lib/form/form.context';
+import {
+ FieldContent,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSeparator,
+ FieldTitle,
+} from '~/ui/components/core/field';
+
+export const { useAppForm } = createFormHook({
+ fieldContext,
+ formContext,
+ fieldComponents: {
+ Field: FormField,
+ FieldSet: FormFieldSet,
+ FieldLabel: FormFieldLabel,
+ FieldError: FormFieldError,
+
+ FieldTitle: FieldTitle,
+ FieldDescription: FieldDescription,
+ FieldContent: FieldContent,
+ FieldLegend: FieldLegend,
+ FieldGroup: FieldGroup,
+ FieldSeparator: FieldSeparator,
+
+ Input: FormInput,
+ Textarea: FormTextarea,
+ Select: FormSelect,
+ CheckboxSingle: FormCheckboxSingle,
+ CheckboxMultiple: FormCheckboxMultiple,
+ RadioGroup: FormRadioGroup,
+ Switch: FormSwitch,
+ },
+ formComponents: {
+ Form: FormRoot,
+ FieldSet: FormFieldSet,
+ FormError: FormError,
+ FormSubmit: FormSubmit,
+ FormReset: FormReset,
+
+ FieldLabel: FieldLabel,
+ FieldTitle: FieldTitle,
+ FieldDescription: FieldDescription,
+ FieldContent: FieldContent,
+ FieldLegend: FieldLegend,
+ FieldGroup: FieldGroup,
+ FieldSeparator: FieldSeparator,
+ },
+});
diff --git a/src/ui/components/core/field.tsx b/src/ui/components/core/field.tsx
index 76f5d20..e93c1a2 100644
--- a/src/ui/components/core/field.tsx
+++ b/src/ui/components/core/field.tsx
@@ -1,5 +1,4 @@
import { cva, type VariantProps } from 'class-variance-authority';
-import { useMemo } from 'react';
import * as React from 'react';
import { Label } from '~/ui/components/core/label';
@@ -17,9 +16,11 @@ function FieldSet({
disabled,
...props
}: React.ComponentProps<'fieldset'>) {
- const parentFieldset = React.use(FieldSetContext);
+ const parentFieldSetState = useFieldSet();
const isDisabled =
- disabled === undefined ? (parentFieldset?.disabled ?? false) : disabled;
+ disabled === undefined
+ ? (parentFieldSetState?.disabled ?? false)
+ : disabled;
return (
@@ -150,7 +151,7 @@ function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
data-slot="field-description"
className={cn(
'text-left text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance [[data-variant=legend]+&]:-mt-1.5',
- 'last:mt-0 nth-last-2:-mt-1',
+ // 'last:mt-0 nth-last-2:-mt-1',
'[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
className,
)}
@@ -179,7 +180,7 @@ function FieldSeparator({
{children && (
{children}
@@ -197,7 +198,7 @@ function FieldError({
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>;
}) {
- const content = useMemo(() => {
+ const content = React.useMemo(() => {
if (children) {
return children;
}
diff --git a/src/ui/components/core/select.tsx b/src/ui/components/core/select.tsx
index c63ed89..4a50683 100644
--- a/src/ui/components/core/select.tsx
+++ b/src/ui/components/core/select.tsx
@@ -42,7 +42,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
- "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
diff --git a/src/ui/components/core/switch.tsx b/src/ui/components/core/switch.tsx
index f67484c..314a468 100644
--- a/src/ui/components/core/switch.tsx
+++ b/src/ui/components/core/switch.tsx
@@ -15,7 +15,7 @@ function Switch({
data-slot="switch"
data-size={size}
className={cn(
- 'peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 dark:data-[state=unchecked]:bg-input/80',
+ 'peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 dark:data-[state=unchecked]:bg-input/80',
className,
)}
{...props}
diff --git a/vite.config.ts b/vite.config.ts
index 8d6776e..003e4bf 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -39,8 +39,8 @@ export default async function viteConfig({ mode }: ConfigEnv) {
},
},
plugins: [
- alchemy({ viteEnvironment: { name: 'ssr' } }),
devtools(),
+ alchemy({ viteEnvironment: { name: 'ssr' } }),
tailwindcss(),
tsConfigPaths({ projects: ['./tsconfig.json'] }),
tanstackStart({ srcDirectory: 'src', router: { routeToken: 'layout' } }),