create-n8-app 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -44,6 +44,21 @@ async function copyTemplate(targetDir, projectName) {
44
44
  // src/utils/install-deps.ts
45
45
  import { execa } from "execa";
46
46
 
47
+ // src/helpers/get-package-manager.ts
48
+ function getPackageManager() {
49
+ const userAgent = process.env.npm_config_user_agent || "";
50
+ if (userAgent.startsWith("pnpm")) {
51
+ return "pnpm";
52
+ }
53
+ if (userAgent.startsWith("yarn")) {
54
+ return "yarn";
55
+ }
56
+ if (userAgent.startsWith("bun")) {
57
+ return "bun";
58
+ }
59
+ return "npm";
60
+ }
61
+
47
62
  // src/utils/logger.ts
48
63
  import chalk from "chalk";
49
64
  var logger = {
@@ -74,22 +89,27 @@ var logger = {
74
89
 
75
90
  // src/utils/install-deps.ts
76
91
  async function installDependencies(targetDir) {
77
- logger.info("Installing dependencies with pnpm...");
92
+ const pm = getPackageManager();
93
+ logger.info(`Installing dependencies with ${pm}...`);
78
94
  try {
79
- await execa("pnpm", ["install"], {
95
+ await execa(pm, ["install"], {
80
96
  cwd: targetDir,
81
97
  stdio: "inherit"
82
98
  });
83
99
  logger.success("Dependencies installed successfully");
84
100
  } catch (error) {
85
- logger.warn("pnpm not found, trying npm...");
86
- try {
87
- await execa("npm", ["install"], {
88
- cwd: targetDir,
89
- stdio: "inherit"
90
- });
91
- logger.success("Dependencies installed successfully with npm");
92
- } catch {
101
+ if (pm !== "npm") {
102
+ logger.warn(`${pm} failed, trying npm...`);
103
+ try {
104
+ await execa("npm", ["install"], {
105
+ cwd: targetDir,
106
+ stdio: "inherit"
107
+ });
108
+ logger.success("Dependencies installed successfully with npm");
109
+ } catch {
110
+ throw new Error("Failed to install dependencies");
111
+ }
112
+ } else {
93
113
  throw new Error("Failed to install dependencies");
94
114
  }
95
115
  }
@@ -101,9 +121,15 @@ async function runShadcnInit(targetDir) {
101
121
  cwd: targetDir,
102
122
  stdio: "inherit"
103
123
  });
104
- logger.success("Shadcn/ui initialized successfully");
124
+ logger.info("Adding base Shadcn/ui components...");
125
+ const baseComponents = ["button", "card", "input", "label", "form", "toast", "textarea"];
126
+ await execa("npx", ["shadcn@latest", "add", ...baseComponents, "-y"], {
127
+ cwd: targetDir,
128
+ stdio: "inherit"
129
+ });
130
+ logger.success("Shadcn/ui initialized with base components");
105
131
  } catch (error) {
106
- logger.warn('Shadcn/ui initialization skipped - you can run "npx shadcn@latest init" later');
132
+ logger.warn('Shadcn/ui setup skipped - run "npx shadcn@latest init" later');
107
133
  }
108
134
  }
109
135
 
@@ -130,21 +156,6 @@ async function initGit(targetDir) {
130
156
  }
131
157
  }
132
158
 
133
- // src/helpers/get-package-manager.ts
134
- function getPackageManager() {
135
- const userAgent = process.env.npm_config_user_agent || "";
136
- if (userAgent.startsWith("pnpm")) {
137
- return "pnpm";
138
- }
139
- if (userAgent.startsWith("yarn")) {
140
- return "yarn";
141
- }
142
- if (userAgent.startsWith("bun")) {
143
- return "bun";
144
- }
145
- return "npm";
146
- }
147
-
148
159
  // src/create-project.ts
149
160
  async function createProject(options) {
150
161
  logger.title("\u{1F680} Create N8 App");
@@ -221,7 +232,7 @@ async function createProject(options) {
221
232
 
222
233
  // src/index.ts
223
234
  var program = new Command();
224
- program.name("create-n8-app").description("Create a new Next.js app with the N8 stack").version("0.1.0").argument("[project-name]", "Name of the project").option("--skip-install", "Skip installing dependencies").option("--skip-git", "Skip initializing git repository").action(async (projectName, options) => {
235
+ program.name("create-n8-app").description("Create a new Next.js app with the N8 stack").version("0.3.0").argument("[project-name]", "Name of the project").option("--skip-install", "Skip installing dependencies").option("--skip-git", "Skip initializing git repository").action(async (projectName, options) => {
225
236
  try {
226
237
  await createProject({
227
238
  projectName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-n8-app",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Create a Next.js app with the N8 stack - Next.js 16, Tailwind v4, Shadcn/ui, Drizzle, tRPC, TanStack Query, Zustand, NextAuth, and more",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,11 +32,11 @@
32
32
  "template",
33
33
  "scaffold"
34
34
  ],
35
- "author": "Nate McG",
35
+ "author": "Nate McGrady",
36
36
  "license": "MIT",
37
37
  "repository": {
38
38
  "type": "git",
39
- "url": "https://github.com/natemcgrady/create-n8-app"
39
+ "url": "https://github.com/nmcgrady/n8-stack"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/fs-extra": "^11.0.4",
@@ -17,43 +17,46 @@
17
17
  "db:studio": "drizzle-kit studio"
18
18
  },
19
19
  "dependencies": {
20
- "@auth/drizzle-adapter": "^1.7.0",
21
- "@neondatabase/serverless": "^0.10.4",
22
- "@tanstack/react-query": "^5.62.0",
23
- "@trpc/client": "^11.0.0-rc.660",
24
- "@trpc/react-query": "^11.0.0-rc.660",
25
- "@trpc/server": "^11.0.0-rc.660",
26
- "ai": "^4.0.0",
27
- "@ai-sdk/openai": "^1.0.0",
28
- "drizzle-orm": "^0.36.4",
29
- "next": "^15.1.0",
30
- "next-auth": "^5.0.0-beta.25",
31
- "react": "^19.0.0",
32
- "react-dom": "^19.0.0",
33
- "zod": "^3.23.8",
34
- "zustand": "^5.0.2",
35
- "superjson": "^2.2.2",
20
+ "@ai-sdk/openai": "^1.3.0",
21
+ "@auth/drizzle-adapter": "^1.11.0",
22
+ "@hookform/resolvers": "^3.9.0",
23
+ "@neondatabase/serverless": "^1.0.0",
24
+ "@tanstack/react-query": "^5.90.0",
25
+ "@trpc/client": "^11.8.0",
26
+ "@trpc/react-query": "^11.8.0",
27
+ "@trpc/server": "^11.8.0",
28
+ "ai": "^4.3.0",
36
29
  "clsx": "^2.1.1",
37
- "tailwind-merge": "^2.6.0",
38
- "lucide-react": "^0.468.0"
30
+ "drizzle-orm": "^0.45.0",
31
+ "lucide-react": "^0.563.0",
32
+ "next": "^16.0.0",
33
+ "next-auth": "^5.0.0-beta.30",
34
+ "react": "^19.2.0",
35
+ "react-dom": "^19.2.0",
36
+ "react-hook-form": "^7.54.0",
37
+ "superjson": "^2.2.6",
38
+ "tailwind-merge": "^3.4.0",
39
+ "zod": "^3.25.0",
40
+ "zustand": "^5.0.10"
39
41
  },
40
42
  "devDependencies": {
41
- "@tailwindcss/postcss": "^4.0.0",
42
- "@types/node": "^22.10.0",
43
- "@types/react": "^19.0.0",
44
- "@types/react-dom": "^19.0.0",
45
- "@vitejs/plugin-react": "^4.3.4",
46
- "drizzle-kit": "^0.28.1",
47
- "jsdom": "^25.0.1",
48
- "postcss": "^8.4.49",
49
- "prettier": "^3.4.2",
50
- "prettier-plugin-tailwindcss": "^0.6.9",
51
- "tailwindcss": "^4.0.0",
52
- "typescript": "^5.7.2",
53
- "vitest": "^2.1.8",
54
- "@testing-library/react": "^16.1.0",
55
- "@testing-library/jest-dom": "^6.6.3",
56
- "eslint": "^9.16.0",
57
- "eslint-config-next": "^15.1.0"
43
+ "@tanstack/react-query-devtools": "^5.90.0",
44
+ "@tailwindcss/postcss": "^4.1.0",
45
+ "@testing-library/jest-dom": "^6.9.0",
46
+ "@testing-library/react": "^16.3.0",
47
+ "@types/node": "^22.19.0",
48
+ "@types/react": "^19.2.0",
49
+ "@types/react-dom": "^19.2.0",
50
+ "@vitejs/plugin-react": "^4.7.0",
51
+ "drizzle-kit": "^0.31.0",
52
+ "eslint": "^9.39.0",
53
+ "eslint-config-next": "^16.0.0",
54
+ "jsdom": "^27.4.0",
55
+ "postcss": "^8.5.0",
56
+ "prettier": "^3.8.0",
57
+ "prettier-plugin-tailwindcss": "^0.7.0",
58
+ "tailwindcss": "^4.1.0",
59
+ "typescript": "^5.9.0",
60
+ "vitest": "^4.0.0"
58
61
  }
59
62
  }
@@ -15,7 +15,7 @@ export default function SignInPage() {
15
15
  >
16
16
  <button
17
17
  type="submit"
18
- className="flex items-center gap-3 rounded-lg bg-slate-800 px-6 py-3 text-white transition-colors hover:bg-slate-700"
18
+ className="flex items-center gap-3 rounded-lg bg-slate-800 px-6 py-3 text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900"
19
19
  >
20
20
  <GitHubIcon />
21
21
  Sign in with GitHub
@@ -28,7 +28,12 @@ export default function SignInPage() {
28
28
 
29
29
  function GitHubIcon() {
30
30
  return (
31
- <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
31
+ <svg
32
+ className="h-5 w-5"
33
+ fill="currentColor"
34
+ viewBox="0 0 24 24"
35
+ aria-hidden="true"
36
+ >
32
37
  <path
33
38
  fillRule="evenodd"
34
39
  d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
@@ -1,25 +1,123 @@
1
1
  @import 'tailwindcss';
2
2
 
3
- @theme {
4
- /* Custom theme configuration */
3
+ @custom-variant dark (&:is(.dark *));
4
+
5
+ @theme inline {
6
+ /* Fonts */
5
7
  --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
6
8
  --font-mono: 'JetBrains Mono', ui-monospace, monospace;
7
9
 
8
- /* Primary colors */
9
- --color-primary-50: oklch(0.97 0.02 250);
10
- --color-primary-100: oklch(0.93 0.04 250);
11
- --color-primary-200: oklch(0.86 0.08 250);
12
- --color-primary-300: oklch(0.76 0.12 250);
13
- --color-primary-400: oklch(0.64 0.16 250);
14
- --color-primary-500: oklch(0.55 0.18 250);
15
- --color-primary-600: oklch(0.48 0.18 250);
16
- --color-primary-700: oklch(0.42 0.16 250);
17
- --color-primary-800: oklch(0.36 0.14 250);
18
- --color-primary-900: oklch(0.30 0.10 250);
19
- --color-primary-950: oklch(0.22 0.08 250);
10
+ /* Border radius */
11
+ --radius-lg: 0.5rem;
12
+ --radius-md: calc(var(--radius-lg) - 2px);
13
+ --radius-sm: calc(var(--radius-lg) - 4px);
14
+
15
+ /* Shadcn/ui color tokens */
16
+ --color-background: hsl(var(--background));
17
+ --color-foreground: hsl(var(--foreground));
18
+ --color-card: hsl(var(--card));
19
+ --color-card-foreground: hsl(var(--card-foreground));
20
+ --color-popover: hsl(var(--popover));
21
+ --color-popover-foreground: hsl(var(--popover-foreground));
22
+ --color-primary: hsl(var(--primary));
23
+ --color-primary-foreground: hsl(var(--primary-foreground));
24
+ --color-secondary: hsl(var(--secondary));
25
+ --color-secondary-foreground: hsl(var(--secondary-foreground));
26
+ --color-muted: hsl(var(--muted));
27
+ --color-muted-foreground: hsl(var(--muted-foreground));
28
+ --color-accent: hsl(var(--accent));
29
+ --color-accent-foreground: hsl(var(--accent-foreground));
30
+ --color-destructive: hsl(var(--destructive));
31
+ --color-destructive-foreground: hsl(var(--destructive-foreground));
32
+ --color-border: hsl(var(--border));
33
+ --color-input: hsl(var(--input));
34
+ --color-ring: hsl(var(--ring));
35
+ --color-chart-1: hsl(var(--chart-1));
36
+ --color-chart-2: hsl(var(--chart-2));
37
+ --color-chart-3: hsl(var(--chart-3));
38
+ --color-chart-4: hsl(var(--chart-4));
39
+ --color-chart-5: hsl(var(--chart-5));
40
+ --color-sidebar: hsl(var(--sidebar-background));
41
+ --color-sidebar-foreground: hsl(var(--sidebar-foreground));
42
+ --color-sidebar-primary: hsl(var(--sidebar-primary));
43
+ --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
44
+ --color-sidebar-accent: hsl(var(--sidebar-accent));
45
+ --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
46
+ --color-sidebar-border: hsl(var(--sidebar-border));
47
+ --color-sidebar-ring: hsl(var(--sidebar-ring));
20
48
  }
21
49
 
22
50
  @layer base {
51
+ :root {
52
+ --background: 0 0% 100%;
53
+ --foreground: 222.2 84% 4.9%;
54
+ --card: 0 0% 100%;
55
+ --card-foreground: 222.2 84% 4.9%;
56
+ --popover: 0 0% 100%;
57
+ --popover-foreground: 222.2 84% 4.9%;
58
+ --primary: 222.2 47.4% 11.2%;
59
+ --primary-foreground: 210 40% 98%;
60
+ --secondary: 210 40% 96.1%;
61
+ --secondary-foreground: 222.2 47.4% 11.2%;
62
+ --muted: 210 40% 96.1%;
63
+ --muted-foreground: 215.4 16.3% 46.9%;
64
+ --accent: 210 40% 96.1%;
65
+ --accent-foreground: 222.2 47.4% 11.2%;
66
+ --destructive: 0 84.2% 60.2%;
67
+ --destructive-foreground: 210 40% 98%;
68
+ --border: 214.3 31.8% 91.4%;
69
+ --input: 214.3 31.8% 91.4%;
70
+ --ring: 222.2 84% 4.9%;
71
+ --chart-1: 12 76% 61%;
72
+ --chart-2: 173 58% 39%;
73
+ --chart-3: 197 37% 24%;
74
+ --chart-4: 43 74% 66%;
75
+ --chart-5: 27 87% 67%;
76
+ --sidebar-background: 0 0% 98%;
77
+ --sidebar-foreground: 240 5.3% 26.1%;
78
+ --sidebar-primary: 240 5.9% 10%;
79
+ --sidebar-primary-foreground: 0 0% 98%;
80
+ --sidebar-accent: 240 4.8% 95.9%;
81
+ --sidebar-accent-foreground: 240 5.9% 10%;
82
+ --sidebar-border: 220 13% 91%;
83
+ --sidebar-ring: 217.2 91.2% 59.8%;
84
+ }
85
+
86
+ .dark {
87
+ --background: 222.2 84% 4.9%;
88
+ --foreground: 210 40% 98%;
89
+ --card: 222.2 84% 4.9%;
90
+ --card-foreground: 210 40% 98%;
91
+ --popover: 222.2 84% 4.9%;
92
+ --popover-foreground: 210 40% 98%;
93
+ --primary: 210 40% 98%;
94
+ --primary-foreground: 222.2 47.4% 11.2%;
95
+ --secondary: 217.2 32.6% 17.5%;
96
+ --secondary-foreground: 210 40% 98%;
97
+ --muted: 217.2 32.6% 17.5%;
98
+ --muted-foreground: 215 20.2% 65.1%;
99
+ --accent: 217.2 32.6% 17.5%;
100
+ --accent-foreground: 210 40% 98%;
101
+ --destructive: 0 62.8% 30.6%;
102
+ --destructive-foreground: 210 40% 98%;
103
+ --border: 217.2 32.6% 17.5%;
104
+ --input: 217.2 32.6% 17.5%;
105
+ --ring: 212.7 26.8% 83.9%;
106
+ --chart-1: 220 70% 50%;
107
+ --chart-2: 160 60% 45%;
108
+ --chart-3: 30 80% 55%;
109
+ --chart-4: 280 65% 60%;
110
+ --chart-5: 340 75% 55%;
111
+ --sidebar-background: 240 5.9% 10%;
112
+ --sidebar-foreground: 240 4.8% 95.9%;
113
+ --sidebar-primary: 224.3 76.3% 48%;
114
+ --sidebar-primary-foreground: 0 0% 100%;
115
+ --sidebar-accent: 240 3.7% 15.9%;
116
+ --sidebar-accent-foreground: 240 4.8% 95.9%;
117
+ --sidebar-border: 240 3.7% 15.9%;
118
+ --sidebar-ring: 217.2 91.2% 59.8%;
119
+ }
120
+
23
121
  * {
24
122
  @apply border-border;
25
123
  }
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
2
2
  import { Inter } from 'next/font/google'
3
3
  import './globals.css'
4
4
  import { Providers } from './providers'
5
+ import { Toaster } from '@/components/ui/toaster'
5
6
 
6
7
  const inter = Inter({
7
8
  subsets: ['latin'],
@@ -22,6 +23,7 @@ export default function RootLayout({
22
23
  <html lang="en" suppressHydrationWarning>
23
24
  <body className={`${inter.variable} font-sans antialiased`}>
24
25
  <Providers>{children}</Providers>
26
+ <Toaster />
25
27
  </body>
26
28
  </html>
27
29
  )
@@ -56,12 +56,16 @@ function FeatureCard({
56
56
  }) {
57
57
  return (
58
58
  <Link
59
- className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 hover:bg-white/20 transition-colors"
59
+ className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 transition-colors hover:bg-white/20"
60
60
  href={href}
61
61
  target="_blank"
62
62
  rel="noopener noreferrer"
63
63
  >
64
- <h3 className="text-2xl font-bold">{title} →</h3>
64
+ <h3 className="text-2xl font-bold">
65
+ {title} →
66
+ {/* Screen reader text for external link */}
67
+ <span className="sr-only"> (opens in a new tab)</span>
68
+ </h3>
65
69
  <p className="text-slate-300">{description}</p>
66
70
  </Link>
67
71
  )
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
4
5
  import { httpBatchLink } from '@trpc/client'
5
6
  import { SessionProvider } from 'next-auth/react'
6
7
  import { useState } from 'react'
@@ -14,12 +15,17 @@ function getBaseUrl() {
14
15
  }
15
16
 
16
17
  export function Providers({ children }: { children: React.ReactNode }) {
18
+ // Lazy init QueryClient to avoid re-creating on every render
19
+ // See: https://tanstack.com/query/latest/docs/framework/react/guides/ssr
17
20
  const [queryClient] = useState(
18
21
  () =>
19
22
  new QueryClient({
20
23
  defaultOptions: {
21
24
  queries: {
22
- staleTime: 5 * 1000,
25
+ // 5 minutes - prevents excessive refetches
26
+ staleTime: 1000 * 60 * 5,
27
+ // 1 hour - garbage collection time for inactive queries
28
+ gcTime: 1000 * 60 * 60,
23
29
  refetchOnWindowFocus: false,
24
30
  },
25
31
  },
@@ -40,7 +46,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
40
46
  return (
41
47
  <SessionProvider>
42
48
  <trpc.Provider client={trpcClient} queryClient={queryClient}>
43
- <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
49
+ <QueryClientProvider client={queryClient}>
50
+ {children}
51
+ <ReactQueryDevtools initialIsOpen={false} />
52
+ </QueryClientProvider>
44
53
  </trpc.Provider>
45
54
  </SessionProvider>
46
55
  )
@@ -8,7 +8,13 @@ export function UserButton() {
8
8
 
9
9
  if (status === 'loading') {
10
10
  return (
11
- <div className="h-8 w-8 animate-pulse rounded-full bg-slate-600" />
11
+ <div
12
+ className="h-8 w-8 animate-pulse rounded-full bg-slate-600"
13
+ role="status"
14
+ aria-label="Loading user information"
15
+ >
16
+ <span className="sr-only">Loading…</span>
17
+ </div>
12
18
  )
13
19
  }
14
20
 
@@ -16,7 +22,7 @@ export function UserButton() {
16
22
  return (
17
23
  <button
18
24
  onClick={() => signIn('github')}
19
- className="rounded-lg bg-white/10 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/20"
25
+ className="rounded-lg bg-white/10 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
20
26
  >
21
27
  Sign In
22
28
  </button>
@@ -28,7 +34,7 @@ export function UserButton() {
28
34
  {session.user.image && (
29
35
  <Image
30
36
  src={session.user.image}
31
- alt={session.user.name ?? 'User'}
37
+ alt={session.user.name ?? 'User avatar'}
32
38
  width={32}
33
39
  height={32}
34
40
  className="rounded-full"
@@ -37,7 +43,7 @@ export function UserButton() {
37
43
  <span className="text-sm text-white">{session.user.name}</span>
38
44
  <button
39
45
  onClick={() => signOut()}
40
- className="rounded-lg bg-white/10 px-3 py-1 text-sm text-white transition-colors hover:bg-white/20"
46
+ className="rounded-lg bg-white/10 px-3 py-1 text-sm text-white transition-colors hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
41
47
  >
42
48
  Sign Out
43
49
  </button>
@@ -0,0 +1,171 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Example Contact Form
5
+ *
6
+ * Demonstrates best practices for React Hook Form + Zod + shadcn/ui:
7
+ * - Type inference with z.infer<typeof schema>
8
+ * - Always set defaultValues (prevents uncontrolled warnings)
9
+ * - zodResolver for validation
10
+ * - shadcn/ui FormField pattern
11
+ * - Error handling with FormMessage
12
+ * - Toast feedback on submit
13
+ *
14
+ * IMPORTANT: Always validate on BOTH client and server (security!)
15
+ * Client validation can be bypassed - server validation is required.
16
+ */
17
+
18
+ import { zodResolver } from '@hookform/resolvers/zod'
19
+ import { useForm } from 'react-hook-form'
20
+ import { z } from 'zod'
21
+
22
+ import { Button } from '@/components/ui/button'
23
+ import {
24
+ Form,
25
+ FormControl,
26
+ FormDescription,
27
+ FormField,
28
+ FormItem,
29
+ FormLabel,
30
+ FormMessage,
31
+ } from '@/components/ui/form'
32
+ import { Input } from '@/components/ui/input'
33
+ import { Textarea } from '@/components/ui/textarea'
34
+ import { useToast } from '@/hooks/use-toast'
35
+
36
+ // Define schema with Zod - single source of truth for types
37
+ const contactFormSchema = z.object({
38
+ name: z
39
+ .string()
40
+ .min(2, { message: 'Name must be at least 2 characters' })
41
+ .max(50, { message: 'Name must be less than 50 characters' }),
42
+ email: z.string().email({ message: 'Please enter a valid email address' }),
43
+ message: z
44
+ .string()
45
+ .min(10, { message: 'Message must be at least 10 characters' })
46
+ .max(500, { message: 'Message must be less than 500 characters' }),
47
+ })
48
+
49
+ // Infer TypeScript type from schema
50
+ type ContactFormData = z.infer<typeof contactFormSchema>
51
+
52
+ export function ContactForm() {
53
+ const { toast } = useToast()
54
+
55
+ // Initialize form with zodResolver and defaultValues
56
+ // CRITICAL: Always set defaultValues to prevent uncontrolled->controlled warnings
57
+ const form = useForm<ContactFormData>({
58
+ resolver: zodResolver(contactFormSchema),
59
+ defaultValues: {
60
+ name: '',
61
+ email: '',
62
+ message: '',
63
+ },
64
+ })
65
+
66
+ async function onSubmit(data: ContactFormData) {
67
+ try {
68
+ // Example: Send to API (always validate server-side too!)
69
+ // const response = await fetch('/api/contact', {
70
+ // method: 'POST',
71
+ // headers: { 'Content-Type': 'application/json' },
72
+ // body: JSON.stringify(data),
73
+ // })
74
+ //
75
+ // if (!response.ok) {
76
+ // const { errors } = await response.json()
77
+ // // Map server errors to form fields
78
+ // Object.entries(errors).forEach(([field, message]) => {
79
+ // form.setError(field as keyof ContactFormData, { message: message as string })
80
+ // })
81
+ // return
82
+ // }
83
+
84
+ console.log('Form submitted:', data)
85
+
86
+ toast({
87
+ title: 'Message sent!',
88
+ description: 'We\'ll get back to you as soon as possible.',
89
+ })
90
+
91
+ form.reset()
92
+ } catch (error) {
93
+ toast({
94
+ variant: 'destructive',
95
+ title: 'Something went wrong',
96
+ description: 'Please try again later.',
97
+ })
98
+ }
99
+ }
100
+
101
+ return (
102
+ <Form {...form}>
103
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
104
+ <FormField
105
+ control={form.control}
106
+ name="name"
107
+ render={({ field }) => (
108
+ <FormItem>
109
+ <FormLabel>Name</FormLabel>
110
+ <FormControl>
111
+ {/* Always spread {...field} in FormControl */}
112
+ <Input placeholder="Your name…" {...field} />
113
+ </FormControl>
114
+ <FormDescription>How should we address you?</FormDescription>
115
+ <FormMessage />
116
+ </FormItem>
117
+ )}
118
+ />
119
+
120
+ <FormField
121
+ control={form.control}
122
+ name="email"
123
+ render={({ field }) => (
124
+ <FormItem>
125
+ <FormLabel>Email</FormLabel>
126
+ <FormControl>
127
+ <Input
128
+ type="email"
129
+ placeholder="you@example.com"
130
+ autoComplete="email"
131
+ spellCheck={false}
132
+ {...field}
133
+ />
134
+ </FormControl>
135
+ <FormMessage />
136
+ </FormItem>
137
+ )}
138
+ />
139
+
140
+ <FormField
141
+ control={form.control}
142
+ name="message"
143
+ render={({ field }) => (
144
+ <FormItem>
145
+ <FormLabel>Message</FormLabel>
146
+ <FormControl>
147
+ <Textarea
148
+ placeholder="Tell us what's on your mind…"
149
+ className="min-h-[120px] resize-none"
150
+ {...field}
151
+ />
152
+ </FormControl>
153
+ <FormDescription>
154
+ {field.value.length}/500 characters
155
+ </FormDescription>
156
+ <FormMessage />
157
+ </FormItem>
158
+ )}
159
+ />
160
+
161
+ <Button
162
+ type="submit"
163
+ className="w-full"
164
+ disabled={form.formState.isSubmitting}
165
+ >
166
+ {form.formState.isSubmitting ? 'Sending…' : 'Send Message'}
167
+ </Button>
168
+ </form>
169
+ </Form>
170
+ )
171
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Example TanStack Query v5 Hooks
3
+ *
4
+ * Demonstrates best practices:
5
+ * - queryOptions factory for reusable query definitions
6
+ * - Object syntax (required in v5)
7
+ * - useMutation with onSuccess invalidation
8
+ * - isPending for loading states (not deprecated isLoading)
9
+ * - Proper typing with TypeScript
10
+ *
11
+ * These hooks wrap tRPC queries but the patterns apply to any data fetching.
12
+ */
13
+
14
+ import { useQueryClient } from '@tanstack/react-query'
15
+ import { trpc } from '@/lib/trpc'
16
+
17
+ /**
18
+ * Hook to fetch all posts
19
+ *
20
+ * Uses tRPC's built-in React Query integration.
21
+ * The query key is automatically managed by tRPC.
22
+ */
23
+ export function usePosts() {
24
+ return trpc.example.getPosts.useQuery(undefined, {
25
+ // v5: staleTime and gcTime are set globally in providers.tsx
26
+ // Override here if needed for specific queries
27
+ })
28
+ }
29
+
30
+ /**
31
+ * Hook to fetch a single post by ID
32
+ *
33
+ * Note: For conditional queries, use the `enabled` option.
34
+ * Don't use enabled with useSuspenseQuery - use conditional rendering instead.
35
+ */
36
+ export function usePost(id: number | undefined) {
37
+ return trpc.example.getPost.useQuery(
38
+ { id: id! },
39
+ {
40
+ // Only fetch when id is defined
41
+ enabled: !!id,
42
+ }
43
+ )
44
+ }
45
+
46
+ /**
47
+ * Hook to create a new post
48
+ *
49
+ * Demonstrates:
50
+ * - useMutation pattern
51
+ * - Invalidating queries on success
52
+ * - Proper error handling
53
+ */
54
+ export function useCreatePost() {
55
+ const queryClient = useQueryClient()
56
+ const utils = trpc.useUtils()
57
+
58
+ return trpc.example.createPost.useMutation({
59
+ onSuccess: () => {
60
+ // Invalidate posts query to refetch the list
61
+ // This triggers a refetch of all queries matching this key
62
+ utils.example.getPosts.invalidate()
63
+ },
64
+ // onError is still available on mutations (removed from queries in v5)
65
+ onError: (error) => {
66
+ console.error('Failed to create post:', error.message)
67
+ },
68
+ })
69
+ }
70
+
71
+ /**
72
+ * Hook to delete a post
73
+ */
74
+ export function useDeletePost() {
75
+ const utils = trpc.useUtils()
76
+
77
+ return trpc.example.deletePost.useMutation({
78
+ onSuccess: () => {
79
+ utils.example.getPosts.invalidate()
80
+ },
81
+ })
82
+ }
83
+
84
+ /**
85
+ * Example: Using the hooks in a component
86
+ *
87
+ * ```tsx
88
+ * function PostsList() {
89
+ * const { data: posts, isPending, isError, error } = usePosts()
90
+ * const createPost = useCreatePost()
91
+ *
92
+ * // v5: Use isPending for initial loading state
93
+ * if (isPending) return <div>Loading...</div>
94
+ * if (isError) return <div>Error: {error.message}</div>
95
+ *
96
+ * return (
97
+ * <div>
98
+ * <button
99
+ * onClick={() => createPost.mutate({ title: 'New Post' })}
100
+ * disabled={createPost.isPending}
101
+ * >
102
+ * {createPost.isPending ? 'Creating...' : 'Create Post'}
103
+ * </button>
104
+ * <ul>
105
+ * {posts.map(post => <li key={post.id}>{post.title}</li>)}
106
+ * </ul>
107
+ * </div>
108
+ * )
109
+ * }
110
+ * ```
111
+ */
@@ -1,5 +1,5 @@
1
1
  import { create } from 'zustand'
2
- import { persist } from 'zustand/middleware'
2
+ import { persist, type PersistStorage } from 'zustand/middleware'
3
3
 
4
4
  /**
5
5
  * Example Zustand store for managing global UI state
@@ -14,6 +14,10 @@ import { persist } from 'zustand/middleware'
14
14
  * - Server data (use React Query instead)
15
15
  * - Form state (use React Hook Form or local state)
16
16
  * - Local component state (use useState)
17
+ *
18
+ * IMPORTANT: Always use version + migrate for localStorage persistence
19
+ * to handle schema changes gracefully. Otherwise, users with old data
20
+ * in localStorage may experience errors or unexpected behavior.
17
21
  */
18
22
 
19
23
  interface UIState {
@@ -51,8 +55,24 @@ export const useUIStore = create<UIState>()(
51
55
  }),
52
56
  {
53
57
  name: 'ui-storage', // localStorage key
58
+ // Version your schema to handle migrations
59
+ version: 1,
60
+ // Migrate from previous versions
61
+ migrate: (persistedState: unknown, version: number) => {
62
+ const state = persistedState as Partial<UIState>
63
+ if (version === 0) {
64
+ // Example migration: v0 -> v1
65
+ // Add default values for new fields
66
+ return {
67
+ ...state,
68
+ sidebarOpen: state.sidebarOpen ?? true,
69
+ theme: state.theme ?? 'system',
70
+ }
71
+ }
72
+ return state as UIState
73
+ },
54
74
  partialize: (state) => ({
55
- // Only persist these fields
75
+ // Only persist these fields (exclude functions and transient state)
56
76
  sidebarOpen: state.sidebarOpen,
57
77
  theme: state.theme,
58
78
  }),
@@ -116,6 +136,21 @@ export const useCartStore = create<CartState>()(
116
136
  }),
117
137
  {
118
138
  name: 'cart-storage',
139
+ // Version your schema to handle migrations
140
+ version: 1,
141
+ // Migrate from previous versions
142
+ migrate: (persistedState: unknown, version: number) => {
143
+ const state = persistedState as Partial<CartState>
144
+ if (version === 0) {
145
+ // Example migration: v0 -> v1
146
+ // Ensure items array exists
147
+ return {
148
+ ...state,
149
+ items: state.items ?? [],
150
+ }
151
+ }
152
+ return state as CartState
153
+ },
119
154
  }
120
155
  )
121
156
  )
@@ -1,21 +0,0 @@
1
- {
2
- "$schema": "https://ui.shadcn.com/schema.json",
3
- "style": "new-york",
4
- "rsc": true,
5
- "tsx": true,
6
- "tailwind": {
7
- "config": "",
8
- "css": "app/globals.css",
9
- "baseColor": "slate",
10
- "cssVariables": true,
11
- "prefix": ""
12
- },
13
- "aliases": {
14
- "components": "@/components",
15
- "utils": "@/lib/utils",
16
- "ui": "@/components/ui",
17
- "lib": "@/lib",
18
- "hooks": "@/hooks"
19
- },
20
- "iconLibrary": "lucide"
21
- }
@@ -1 +0,0 @@
1
- # Custom React hooks go here