better-ts-stack 2.1.1 → 2.2.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.
Files changed (174) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +16 -8
  3. package/dist/args.js +7 -0
  4. package/dist/builder/buildPlanner.js +7 -1
  5. package/dist/builder/configGenerator.js +28 -3
  6. package/dist/builder/index.js +2 -1
  7. package/dist/builder/moduleSelector.js +6 -1
  8. package/dist/builder/readmeGenerator.js +8 -2
  9. package/dist/builder/templateContext.js +1 -0
  10. package/dist/output/nextSteps.js +2 -4
  11. package/dist/prompts/stack.js +6 -1
  12. package/dist/types/index.js +2 -0
  13. package/dist/ui/messages.js +2 -0
  14. package/package.json +71 -71
  15. package/templates/backend/express/.prettierignore +52 -52
  16. package/templates/backend/express/.prettierrc +16 -16
  17. package/templates/backend/express/gitignore +3 -1
  18. package/templates/backend/express/src/env.ts.hbs +13 -2
  19. package/templates/backend/express/src/index.ts.hbs +31 -2
  20. package/templates/backend/express/tsconfig.eslint.json +9 -9
  21. package/templates/backend/nestjs/.prettierignore +52 -52
  22. package/templates/backend/nestjs/gitignore +3 -1
  23. package/templates/backend/nestjs/src/app.module.ts.hbs +4 -1
  24. package/templates/backend/nestjs/src/env.ts.hbs +13 -2
  25. package/templates/backend/nestjs/src/health/health.controller.ts.hbs +7 -0
  26. package/templates/backend/nestjs/src/{main.ts → main.ts.hbs} +11 -0
  27. package/templates/backend/nestjs/tsconfig.eslint.json +9 -9
  28. package/templates/frontend/nextjs/app/globals.css +129 -89
  29. package/templates/frontend/nextjs/app/layout.tsx +36 -34
  30. package/templates/frontend/nextjs/app/page.tsx.hbs +63 -43
  31. package/templates/frontend/nextjs/components/ui/alert.tsx +75 -0
  32. package/templates/frontend/nextjs/components/ui/badge.tsx +51 -0
  33. package/templates/frontend/nextjs/components/ui/button.tsx +57 -51
  34. package/templates/frontend/nextjs/components/ui/card.tsx +102 -60
  35. package/templates/frontend/nextjs/components/ui/field.tsx +238 -67
  36. package/templates/frontend/nextjs/components/ui/input.tsx +19 -18
  37. package/templates/frontend/nextjs/components/ui/label.tsx +19 -0
  38. package/templates/frontend/nextjs/components/ui/separator.tsx +24 -0
  39. package/templates/frontend/nextjs/components/ui/skeleton.tsx +13 -0
  40. package/templates/frontend/nextjs/components/ui/spinner.tsx +10 -0
  41. package/templates/frontend/nextjs/components.json +11 -5
  42. package/templates/frontend/nextjs/config.json +36 -35
  43. package/templates/frontend/nextjs/eslint.config.mjs +11 -11
  44. package/templates/frontend/nextjs/lib/utils.ts +1 -6
  45. package/templates/frontend/nextjs/next.config.ts.hbs +12 -12
  46. package/templates/frontend/nextjs/postcss.config.mjs +7 -7
  47. package/templates/frontend/nextjs/public/file.svg +1 -1
  48. package/templates/frontend/nextjs/public/globe.svg +1 -1
  49. package/templates/frontend/nextjs/public/window.svg +1 -1
  50. package/templates/frontend/tanstack/components.json +25 -18
  51. package/templates/frontend/tanstack/config.json +5 -2
  52. package/templates/frontend/tanstack/eslint.config.mjs +23 -23
  53. package/templates/frontend/tanstack/gitignore +39 -39
  54. package/templates/frontend/tanstack/public/favicon.svg +7 -7
  55. package/templates/frontend/tanstack/src/components/ui/alert.tsx +75 -0
  56. package/templates/frontend/tanstack/src/components/ui/badge.tsx +51 -0
  57. package/templates/frontend/tanstack/src/components/ui/button.tsx +57 -51
  58. package/templates/frontend/tanstack/src/components/ui/card.tsx +102 -60
  59. package/templates/frontend/tanstack/src/components/ui/field.tsx +236 -67
  60. package/templates/frontend/tanstack/src/components/ui/input.tsx +19 -18
  61. package/templates/frontend/tanstack/src/components/ui/label.tsx +17 -0
  62. package/templates/frontend/tanstack/src/components/ui/separator.tsx +24 -0
  63. package/templates/frontend/tanstack/src/components/ui/skeleton.tsx +13 -0
  64. package/templates/frontend/tanstack/src/components/ui/spinner.tsx +10 -0
  65. package/templates/frontend/tanstack/src/lib/utils.ts +1 -6
  66. package/templates/frontend/tanstack/src/router.tsx +31 -31
  67. package/templates/frontend/tanstack/src/routes/__root.tsx +69 -69
  68. package/templates/frontend/tanstack/src/routes/index.tsx.hbs +64 -45
  69. package/templates/frontend/tanstack/src/styles/app.css +100 -55
  70. package/templates/frontend/tanstack/tsconfig.json +22 -22
  71. package/templates/modules/agents/AGENTS.md.hbs +245 -0
  72. package/templates/modules/agents/CLAUDE.md +1 -0
  73. package/templates/modules/agents/config.json +10 -0
  74. package/templates/modules/auth/express/config.json +12 -11
  75. package/templates/modules/auth/express/src/lib/auth.ts.hbs +143 -0
  76. package/templates/modules/auth/express/src/lib/email.ts +37 -0
  77. package/templates/modules/auth/express/src/middleware/requireAuth.ts +29 -0
  78. package/templates/modules/auth/express/src/routes/me.ts +13 -0
  79. package/templates/modules/auth/nestjs/config.json +13 -11
  80. package/templates/modules/auth/nestjs/src/auth/auth.module.ts.hbs +64 -14
  81. package/templates/modules/auth/nestjs/src/auth/auth.ts.hbs +152 -0
  82. package/templates/modules/auth/nestjs/src/auth/email.ts +37 -0
  83. package/templates/modules/auth/nestjs/src/me/me.controller.ts +15 -0
  84. package/templates/modules/auth/nextjs/app/api/auth/[...all]/route.ts +4 -4
  85. package/templates/modules/auth/nextjs/app/dashboard/page.tsx +101 -45
  86. package/templates/modules/auth/nextjs/app/forgot-password/page.tsx +11 -0
  87. package/templates/modules/auth/nextjs/app/reset-password/page.tsx +17 -0
  88. package/templates/modules/auth/nextjs/app/sign-in/page.tsx +23 -17
  89. package/templates/modules/auth/nextjs/app/sign-up/page.tsx +25 -19
  90. package/templates/modules/auth/nextjs/components/auth/change-password-form.tsx +125 -0
  91. package/templates/modules/auth/nextjs/components/auth/email-verification-card.tsx +91 -0
  92. package/templates/modules/auth/nextjs/components/auth/forgot-password-form.tsx +133 -0
  93. package/templates/modules/auth/nextjs/components/auth/reset-password-form.tsx +155 -0
  94. package/templates/modules/auth/nextjs/components/auth/sign-in-form.tsx +46 -32
  95. package/templates/modules/auth/nextjs/components/auth/sign-out-button.tsx +6 -5
  96. package/templates/modules/auth/nextjs/components/auth/sign-up-form.tsx +36 -30
  97. package/templates/modules/auth/nextjs/components/auth/social-buttons.tsx +73 -0
  98. package/templates/modules/auth/nextjs/config.json +24 -18
  99. package/templates/modules/auth/nextjs/lib/auth-client.ts +3 -3
  100. package/templates/modules/auth/nextjs/lib/auth-schema.ts +27 -2
  101. package/templates/modules/auth/nextjs/lib/auth.ts.hbs +65 -0
  102. package/templates/modules/auth/nextjs/lib/email.ts +43 -0
  103. package/templates/modules/auth/nextjs/lib/session.ts +12 -0
  104. package/templates/modules/auth/tanstack/config.json +25 -19
  105. package/templates/modules/auth/tanstack/src/components/auth/change-password-form.tsx +123 -0
  106. package/templates/modules/auth/tanstack/src/components/auth/email-verification-card.tsx +89 -0
  107. package/templates/modules/auth/tanstack/src/components/auth/forgot-password-form.tsx +131 -0
  108. package/templates/modules/auth/tanstack/src/components/auth/reset-password-form.tsx +149 -0
  109. package/templates/modules/auth/tanstack/src/components/auth/sign-in-form.tsx +44 -35
  110. package/templates/modules/auth/tanstack/src/components/auth/sign-out-button.tsx +7 -6
  111. package/templates/modules/auth/tanstack/src/components/auth/sign-up-form.tsx +37 -34
  112. package/templates/modules/auth/tanstack/src/components/auth/social-buttons.tsx +71 -0
  113. package/templates/modules/auth/tanstack/src/lib/auth-client.ts +2 -2
  114. package/templates/modules/auth/tanstack/src/lib/auth-functions.ts +14 -8
  115. package/templates/modules/auth/tanstack/src/lib/auth-schema.ts +27 -2
  116. package/templates/modules/auth/tanstack/src/lib/auth.ts.hbs +98 -38
  117. package/templates/modules/auth/tanstack/src/lib/email.ts +43 -0
  118. package/templates/modules/auth/tanstack/src/routeTree.gen.ts +61 -3
  119. package/templates/modules/auth/tanstack/src/routes/api/auth/$.ts +15 -15
  120. package/templates/modules/auth/tanstack/src/routes/dashboard.tsx +119 -111
  121. package/templates/modules/auth/tanstack/src/routes/forgot-password.tsx +17 -0
  122. package/templates/modules/auth/tanstack/src/routes/reset-password.tsx +25 -0
  123. package/templates/modules/auth/tanstack/src/routes/sign-in.tsx +44 -37
  124. package/templates/modules/auth/tanstack/src/routes/sign-up.tsx +44 -37
  125. package/templates/modules/docker/.dockerignore +2 -0
  126. package/templates/modules/docker/config.json +15 -15
  127. package/templates/modules/drizzle/express/config.json +26 -26
  128. package/templates/modules/drizzle/express/src/lib/schema.ts.hbs +90 -0
  129. package/templates/modules/drizzle/nestjs/config.json +26 -26
  130. package/templates/modules/drizzle/nestjs/src/database/schema.ts.hbs +90 -0
  131. package/templates/modules/drizzle/nextjs/config.json +26 -26
  132. package/templates/modules/drizzle/nextjs/drizzle.config.ts +13 -13
  133. package/templates/modules/drizzle/tanstack/config.json +26 -26
  134. package/templates/modules/drizzle/tanstack/drizzle.config.ts +12 -12
  135. package/templates/modules/drizzle/tanstack/src/lib/db.ts +13 -13
  136. package/templates/modules/mongoose/express/config.json +15 -15
  137. package/templates/modules/mongoose/express/src/lib/db.ts +43 -43
  138. package/templates/modules/mongoose/express/src/models/User.ts +48 -48
  139. package/templates/modules/mongoose/nestjs/config.json +16 -15
  140. package/templates/modules/mongoose/nextjs/config.json +15 -15
  141. package/templates/modules/mongoose/nextjs/models/User.ts +48 -48
  142. package/templates/modules/mongoose/tanstack/config.json +16 -16
  143. package/templates/modules/mongoose/tanstack/src/models/User.ts +48 -48
  144. package/templates/modules/prisma/express/config.json +27 -27
  145. package/templates/modules/prisma/express/prisma/schema.prisma.hbs +92 -0
  146. package/templates/modules/prisma/nestjs/config.json +27 -27
  147. package/templates/modules/prisma/nestjs/prisma/schema.prisma.hbs +91 -0
  148. package/templates/modules/prisma/nextjs/config.json +28 -28
  149. package/templates/modules/prisma/nextjs/lib/prisma.ts +18 -18
  150. package/templates/modules/prisma/nextjs/prisma.config.ts +14 -14
  151. package/templates/modules/prisma/tanstack/config.json +28 -28
  152. package/templates/modules/prisma/tanstack/prisma/schema.prisma.hbs +91 -91
  153. package/templates/modules/prisma/tanstack/prisma.config.ts +14 -14
  154. package/templates/modules/prisma/tanstack/src/lib/prisma.ts +17 -17
  155. package/templates/modules/rate-limit/express/src/middleware/rateLimit.ts +0 -8
  156. package/templates/modules/vscode/.vscode/extensions.json.hbs +29 -0
  157. package/templates/modules/vscode/.vscode/settings.json.hbs +22 -0
  158. package/templates/modules/vscode/config.json +10 -0
  159. package/templates/modules/auth/express/src/controllers/authController.ts.hbs +0 -116
  160. package/templates/modules/auth/express/src/lib/jwt.ts.hbs +0 -27
  161. package/templates/modules/auth/express/src/middleware/requireAuth.ts.hbs +0 -25
  162. package/templates/modules/auth/express/src/routes/auth.ts.hbs +0 -27
  163. package/templates/modules/auth/express/src/services/userStore.ts.hbs +0 -117
  164. package/templates/modules/auth/nestjs/src/auth/auth.controller.ts.hbs +0 -61
  165. package/templates/modules/auth/nestjs/src/auth/auth.guard.ts +0 -36
  166. package/templates/modules/auth/nestjs/src/auth/auth.schemas.ts +0 -35
  167. package/templates/modules/auth/nestjs/src/auth/auth.service.ts +0 -70
  168. package/templates/modules/auth/nestjs/src/auth/jwt.ts +0 -27
  169. package/templates/modules/auth/nestjs/src/auth/users.service.ts.hbs +0 -144
  170. package/templates/modules/auth/nestjs/src/auth/zod-validation.pipe.ts +0 -16
  171. package/templates/modules/drizzle/express/src/lib/schema.ts +0 -12
  172. package/templates/modules/drizzle/nestjs/src/database/schema.ts +0 -12
  173. package/templates/modules/prisma/express/prisma/schema.prisma +0 -24
  174. package/templates/modules/prisma/nestjs/prisma/schema.prisma +0 -23
@@ -1,20 +1,21 @@
1
1
  {
2
2
  "id": "auth",
3
- "name": "JWT Auth",
4
- "description": "JWT authentication with bcrypt password hashing",
3
+ "name": "Better Auth",
4
+ "description": "Better Auth for Express: email and password, email verification, password reset, GitHub and Google sign-in, and bearer tokens",
5
5
  "type": "feature",
6
6
  "dependencies": {
7
- "bcrypt": "^6.0.0",
8
- "jsonwebtoken": "^9.0.3",
9
- "zod": "^4.6.7"
10
- },
11
- "devDependencies": {
12
- "@types/bcrypt": "^6.0.0",
13
- "@types/jsonwebtoken": "^9.0.10"
7
+ "better-auth": "^1.7.18"
14
8
  },
9
+ "devDependencies": {},
15
10
  "scripts": {},
16
11
  "envVars": {
17
- "JWT_SECRET": "please-change-me",
18
- "JWT_EXPIRES_IN": "1h"
12
+ "BETTER_AUTH_SECRET": "please-change-me-to-a-random-string",
13
+ "BETTER_AUTH_URL": "http://localhost:3000",
14
+ "GITHUB_CLIENT_ID": "",
15
+ "GITHUB_CLIENT_SECRET": "",
16
+ "GOOGLE_CLIENT_ID": "",
17
+ "GOOGLE_CLIENT_SECRET": "",
18
+ "RESEND_API_KEY": "",
19
+ "EMAIL_FROM": "onboarding@resend.dev"
19
20
  }
20
21
  }
@@ -0,0 +1,143 @@
1
+ import { betterAuth } from "better-auth";
2
+ {{#if (eq database 'none')}}
3
+ import { memoryAdapter } from "better-auth/adapters/memory";
4
+ {{/if}}
5
+ {{#if (eq database 'prisma')}}
6
+ import { prismaAdapter } from "better-auth/adapters/prisma";
7
+ {{/if}}
8
+ {{#if (eq database 'drizzle')}}
9
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
10
+ {{/if}}
11
+ {{#if (eq database 'mongoose')}}
12
+ import { mongodbAdapter } from "better-auth/adapters/mongodb";
13
+ import { MongoClient } from "mongodb";
14
+ {{/if}}
15
+ import { bearer } from "better-auth/plugins";
16
+
17
+ import { env } from "../env";
18
+ {{#if (eq database 'drizzle')}}
19
+ import { db } from "./db";
20
+ {{/if}}
21
+ import { sendEmail } from "./email";
22
+ {{#if (eq database 'prisma')}}
23
+ import { prisma } from "./prisma";
24
+ {{/if}}
25
+
26
+ {{#if (eq database 'none')}}
27
+ // In-memory store for trying the API out: users and sessions are lost on
28
+ // restart, so add a database before going to production
29
+ const memoryDb = { user: [], session: [], account: [], verification: [] };
30
+
31
+ {{/if}}
32
+ {{#if (eq database 'mongoose')}}
33
+ // Better Auth talks to MongoDB through the driver, next to Mongoose
34
+ export const mongoClient = new MongoClient(env.MONGODB_URI);
35
+
36
+ {{/if}}
37
+ // Reports a failed email instead of failing the request: the sign-up or reset
38
+ // already happened, and the user can ask for the email again
39
+ function deliver(email: Parameters<typeof sendEmail>[0]): void {
40
+ sendEmail(email).catch((error: unknown) => {
41
+ console.error(`Could not send "${email.subject}" to ${email.to}:`, error);
42
+ });
43
+ }
44
+
45
+ // Points a verification link at the API itself when the client gave no page
46
+ // to return to, so opening it answers with JSON instead of a redirect to "/"
47
+ function withoutDefaultCallback(url: string): string {
48
+ const link = new URL(url);
49
+ if (link.searchParams.get("callbackURL") === "/") {
50
+ link.searchParams.delete("callbackURL");
51
+ }
52
+ return link.toString();
53
+ }
54
+
55
+ export const auth = betterAuth({
56
+ {{#if (eq database 'none')}}
57
+ database: memoryAdapter(memoryDb),
58
+ {{/if}}
59
+ {{#if (eq database 'prisma')}}
60
+ database: prismaAdapter(prisma, { provider: "postgresql" }),
61
+ {{/if}}
62
+ {{#if (eq database 'drizzle')}}
63
+ database: drizzleAdapter(db, { provider: "pg" }),
64
+ {{/if}}
65
+ {{#if (eq database 'mongoose')}}
66
+ // Without the client, writes run without transactions, which a standalone
67
+ // MongoDB server (no replica set) does not support
68
+ database: mongodbAdapter(mongoClient.db()),
69
+ {{/if}}
70
+ secret: env.BETTER_AUTH_SECRET,
71
+ baseURL: env.BETTER_AUTH_URL,
72
+ // Browser apps on these origins may call the auth routes with cookies
73
+ trustedOrigins: typeof env.CORS_ORIGIN === "string" ? [] : env.CORS_ORIGIN,
74
+ emailAndPassword: {
75
+ enabled: true,
76
+ // Set to true to block sign-in until the email address is verified
77
+ requireEmailVerification: false,
78
+ // A reset signs the user out everywhere
79
+ revokeSessionsOnPasswordReset: true,
80
+ sendResetPassword: ({ user, url, token }) => {
81
+ deliver({
82
+ to: user.email,
83
+ subject: "Reset your password",
84
+ text: `Open this link to choose a new password:\n${url}\n\nOr send this token to POST /api/auth/reset-password: ${token}`,
85
+ });
86
+ return Promise.resolve();
87
+ },
88
+ },
89
+ emailVerification: {
90
+ sendOnSignUp: true,
91
+ autoSignInAfterVerification: true,
92
+ sendVerificationEmail: ({ user, url }) => {
93
+ deliver({
94
+ to: user.email,
95
+ subject: "Verify your email address",
96
+ text: `Open this link to verify your email address:\n${withoutDefaultCallback(url)}`,
97
+ });
98
+ return Promise.resolve();
99
+ },
100
+ },
101
+ // Each provider is turned on once its client ID and secret are set
102
+ socialProviders: {
103
+ ...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
104
+ ? {
105
+ github: {
106
+ clientId: env.GITHUB_CLIENT_ID,
107
+ clientSecret: env.GITHUB_CLIENT_SECRET,
108
+ },
109
+ }
110
+ : {}),
111
+ ...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
112
+ ? {
113
+ google: {
114
+ clientId: env.GOOGLE_CLIENT_ID,
115
+ clientSecret: env.GOOGLE_CLIENT_SECRET,
116
+ },
117
+ }
118
+ : {}),
119
+ },
120
+ // Clients without cookies send "Authorization: Bearer <token>"; the token
121
+ // comes back in the set-auth-token header of sign-in and sign-up
122
+ plugins: [bearer()],
123
+ {{#if useRateLimit}}
124
+ // Sign-up, sign-in, and password reset: 10 attempts per client IP in 15
125
+ // minutes, against password guessing
126
+ rateLimit: {
127
+ enabled: true,
128
+ window: 15 * 60,
129
+ max: 100,
130
+ customRules: {
131
+ "/sign-in/*": { window: 15 * 60, max: 10 },
132
+ "/sign-up/*": { window: 15 * 60, max: 10 },
133
+ "/request-password-reset": { window: 15 * 60, max: 10 },
134
+ },
135
+ },
136
+ {{/if}}
137
+ advanced: {
138
+ // Set by the server from req.ip on every request (see index.ts)
139
+ ipAddress: { ipAddressHeaders: ["x-client-ip"] },
140
+ },
141
+ });
142
+
143
+ export type Session = typeof auth.$Infer.Session;
@@ -0,0 +1,37 @@
1
+ import { env } from "../env";
2
+
3
+ interface Email {
4
+ to: string;
5
+ subject: string;
6
+ text: string;
7
+ }
8
+
9
+ if (!env.RESEND_API_KEY && process.env.NODE_ENV === "production") {
10
+ console.warn(
11
+ "RESEND_API_KEY is not set: emails are printed to the log instead of sent"
12
+ );
13
+ }
14
+
15
+ // Sends through Resend when RESEND_API_KEY is set. Without it the email is
16
+ // printed, so verification and reset links can be copied from the terminal
17
+ export async function sendEmail({ to, subject, text }: Email): Promise<void> {
18
+ if (!env.RESEND_API_KEY) {
19
+ console.log(`\n--- Email to ${to}: ${subject}\n${text}\n---\n`);
20
+ return;
21
+ }
22
+
23
+ const response = await fetch("https://api.resend.com/emails", {
24
+ method: "POST",
25
+ headers: {
26
+ Authorization: `Bearer ${env.RESEND_API_KEY}`,
27
+ "Content-Type": "application/json",
28
+ },
29
+ body: JSON.stringify({ from: env.EMAIL_FROM, to, subject, text }),
30
+ });
31
+
32
+ if (!response.ok) {
33
+ throw new Error(
34
+ `Resend rejected the email (${response.status}): ${await response.text()}`
35
+ );
36
+ }
37
+ }
@@ -0,0 +1,29 @@
1
+ import { fromNodeHeaders } from "better-auth/node";
2
+ import type { NextFunction, Request, Response } from "express";
3
+
4
+ import { auth, type Session } from "../lib/auth";
5
+
6
+ // Accepts a session cookie or an "Authorization: Bearer <token>" header and
7
+ // stores the session in res.locals.session; answers 401 without one
8
+ export function requireAuth(
9
+ req: Request,
10
+ res: Response,
11
+ next: NextFunction
12
+ ): void {
13
+ auth.api
14
+ .getSession({ headers: fromNodeHeaders(req.headers) })
15
+ .then((session) => {
16
+ if (!session) {
17
+ res.status(401).json({ error: "Unauthorized" });
18
+ return;
19
+ }
20
+ res.locals.session = session;
21
+ next();
22
+ })
23
+ .catch(next);
24
+ }
25
+
26
+ // The session requireAuth stored, for handlers that run after it
27
+ export function getSession(res: Response): Session {
28
+ return res.locals.session as Session;
29
+ }
@@ -0,0 +1,13 @@
1
+ import { Request, Response, Router } from "express";
2
+
3
+ import { getSession, requireAuth } from "../middleware/requireAuth";
4
+
5
+ const router: Router = Router();
6
+
7
+ // Example of a protected route: returns the signed-in user
8
+ router.get("/", requireAuth, (_req: Request, res: Response) => {
9
+ const { user, session } = getSession(res);
10
+ res.status(200).json({ user, session: { expiresAt: session.expiresAt } });
11
+ });
12
+
13
+ export default router;
@@ -1,20 +1,22 @@
1
1
  {
2
2
  "id": "auth",
3
- "name": "JWT Auth",
4
- "description": "JWT authentication for NestJS with bcrypt password hashing",
3
+ "name": "Better Auth",
4
+ "description": "Better Auth for NestJS: email and password, email verification, password reset, GitHub and Google sign-in, and bearer tokens",
5
5
  "type": "feature",
6
6
  "dependencies": {
7
- "bcrypt": "^6.0.0",
8
- "jsonwebtoken": "^9.0.3",
9
- "zod": "^4.6.7"
10
- },
11
- "devDependencies": {
12
- "@types/bcrypt": "^6.0.0",
13
- "@types/jsonwebtoken": "^9.0.10"
7
+ "@thallesp/nestjs-better-auth": "^2.8.0",
8
+ "better-auth": "^1.7.18"
14
9
  },
10
+ "devDependencies": {},
15
11
  "scripts": {},
16
12
  "envVars": {
17
- "JWT_SECRET": "please-change-me",
18
- "JWT_EXPIRES_IN": "1h"
13
+ "BETTER_AUTH_SECRET": "please-change-me-to-a-random-string",
14
+ "BETTER_AUTH_URL": "http://localhost:3000",
15
+ "GITHUB_CLIENT_ID": "",
16
+ "GITHUB_CLIENT_SECRET": "",
17
+ "GOOGLE_CLIENT_ID": "",
18
+ "GOOGLE_CLIENT_SECRET": "",
19
+ "RESEND_API_KEY": "",
20
+ "EMAIL_FROM": "onboarding@resend.dev"
19
21
  }
20
22
  }
@@ -1,22 +1,72 @@
1
- import { Module } from "@nestjs/common";
2
1
  {{#if (eq database 'mongoose')}}
3
- import { MongooseModule } from "@nestjs/mongoose";
2
+ import {
3
+ Injectable,
4
+ Module,
5
+ type OnApplicationShutdown,
6
+ } from "@nestjs/common";
7
+ {{/if}}
8
+ import { AuthModule as BetterAuthModule } from "@thallesp/nestjs-better-auth";
9
+ import type { Request, Response } from "express";
10
+ {{#if (eq database 'mongoose')}}
11
+ import { MongoClient } from "mongodb";
4
12
  {{/if}}
5
13
 
14
+ {{#if (eq database 'drizzle')}}
15
+ import { type Database, DRIZZLE } from "../database/database.module.js";
16
+ {{/if}}
6
17
  {{#if (eq database 'mongoose')}}
7
- import { User, UserSchema } from "../users/user.schema.js";
18
+ import { env } from "../env.js";
8
19
  {{/if}}
9
- import { AuthController } from "./auth.controller.js";
10
- import { AuthService } from "./auth.service.js";
11
- import { UsersService } from "./users.service.js";
20
+ {{#if (eq database 'prisma')}}
21
+ import { PrismaService } from "../prisma/prisma.service.js";
22
+ {{/if}}
23
+ import { createAuth } from "./auth.js";
24
+
25
+ {{#if (eq database 'mongoose')}}
26
+ // Better Auth talks to MongoDB through the driver, next to Mongoose
27
+ const mongoClient = new MongoClient(env.MONGODB_URI);
12
28
 
13
- @Module({
29
+ @Injectable()
30
+ class MongoClientShutdown implements OnApplicationShutdown {
31
+ onApplicationShutdown(): Promise<void> {
32
+ return mongoClient.close();
33
+ }
34
+ }
35
+
36
+ @Module({ providers: [MongoClientShutdown] })
37
+ class MongoClientModule {}
38
+
39
+ {{/if}}
40
+ // Mounts Better Auth on /api/auth and protects every other route: add
41
+ // @AllowAnonymous() to a controller or route that should stay public
42
+ export const AuthModule = BetterAuthModule.forRootAsync({
43
+ {{#if (eq database 'prisma')}}
44
+ inject: [PrismaService],
45
+ useFactory: (prisma: PrismaService) => ({
46
+ auth: createAuth(prisma),
47
+ {{/if}}
48
+ {{#if (eq database 'drizzle')}}
49
+ inject: [DRIZZLE],
50
+ useFactory: (db: Database) => ({
51
+ auth: createAuth(db),
52
+ {{/if}}
14
53
  {{#if (eq database 'mongoose')}}
15
- imports: [
16
- MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
17
- ],
54
+ imports: [MongoClientModule],
55
+ useFactory: () => ({
56
+ auth: createAuth(mongoClient.db()),
57
+ {{/if}}
58
+ {{#if (eq database 'none')}}
59
+ useFactory: () => ({
60
+ auth: createAuth(),
18
61
  {{/if}}
19
- controllers: [AuthController],
20
- providers: [AuthService, UsersService],
21
- })
22
- export class AuthModule {}
62
+ // CORS stays with app.enableCors() in main.ts, which reads CORS_ORIGIN
63
+ disableTrustedOriginsCors: true,
64
+ // Better Auth reads the client IP for its rate limits from this header. It
65
+ // is set from req.ip on every request, so a client cannot pick its own
66
+ // value. Behind a reverse proxy, enable "trust proxy" for the real client IP
67
+ middleware: (req: Request, _res: Response, next: () => void) => {
68
+ req.headers["x-client-ip"] = req.ip;
69
+ next();
70
+ },
71
+ }),
72
+ });
@@ -0,0 +1,152 @@
1
+ import { betterAuth } from "better-auth";
2
+ {{#if (eq database 'none')}}
3
+ import { memoryAdapter } from "better-auth/adapters/memory";
4
+ {{/if}}
5
+ {{#if (eq database 'prisma')}}
6
+ import { prismaAdapter } from "better-auth/adapters/prisma";
7
+ {{/if}}
8
+ {{#if (eq database 'drizzle')}}
9
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
10
+ {{/if}}
11
+ {{#if (eq database 'mongoose')}}
12
+ import { mongodbAdapter } from "better-auth/adapters/mongodb";
13
+ {{/if}}
14
+ import { bearer } from "better-auth/plugins";
15
+ {{#if (eq database 'mongoose')}}
16
+ import type { Db } from "mongodb";
17
+ {{/if}}
18
+
19
+ {{#if (eq database 'drizzle')}}
20
+ import type { Database } from "../database/database.module.js";
21
+ {{/if}}
22
+ import { env } from "../env.js";
23
+ {{#if (eq database 'prisma')}}
24
+ import type { PrismaService } from "../prisma/prisma.service.js";
25
+ {{/if}}
26
+ import { sendEmail } from "./email.js";
27
+
28
+ {{#if (eq database 'none')}}
29
+ // In-memory store for trying the API out: users and sessions are lost on
30
+ // restart, so add a database before going to production
31
+ const memoryDb = { user: [], session: [], account: [], verification: [] };
32
+
33
+ {{/if}}
34
+ // Reports a failed email instead of failing the request: the sign-up or reset
35
+ // already happened, and the user can ask for the email again
36
+ function deliver(email: Parameters<typeof sendEmail>[0]): void {
37
+ sendEmail(email).catch((error: unknown) => {
38
+ console.error(`Could not send "${email.subject}" to ${email.to}:`, error);
39
+ });
40
+ }
41
+
42
+ // Points a verification link at the API itself when the client gave no page
43
+ // to return to, so opening it answers with JSON instead of a redirect to "/"
44
+ function withoutDefaultCallback(url: string): string {
45
+ const link = new URL(url);
46
+ if (link.searchParams.get("callbackURL") === "/") {
47
+ link.searchParams.delete("callbackURL");
48
+ }
49
+ return link.toString();
50
+ }
51
+
52
+ // Builds the Better Auth server; auth.module.ts calls it with the database
53
+ {{#if (eq database 'none')}}
54
+ export function createAuth() {
55
+ {{/if}}
56
+ {{#if (eq database 'prisma')}}
57
+ export function createAuth(prisma: PrismaService) {
58
+ {{/if}}
59
+ {{#if (eq database 'drizzle')}}
60
+ export function createAuth(db: Database) {
61
+ {{/if}}
62
+ {{#if (eq database 'mongoose')}}
63
+ export function createAuth(db: Db) {
64
+ {{/if}}
65
+ return betterAuth({
66
+ {{#if (eq database 'none')}}
67
+ database: memoryAdapter(memoryDb),
68
+ {{/if}}
69
+ {{#if (eq database 'prisma')}}
70
+ database: prismaAdapter(prisma, { provider: "postgresql" }),
71
+ {{/if}}
72
+ {{#if (eq database 'drizzle')}}
73
+ database: drizzleAdapter(db, { provider: "pg" }),
74
+ {{/if}}
75
+ {{#if (eq database 'mongoose')}}
76
+ // Without the client, writes run without transactions, which a standalone
77
+ // MongoDB server (no replica set) does not support
78
+ database: mongodbAdapter(db),
79
+ {{/if}}
80
+ secret: env.BETTER_AUTH_SECRET,
81
+ baseURL: env.BETTER_AUTH_URL,
82
+ // Browser apps on these origins may call the auth routes with cookies
83
+ trustedOrigins: typeof env.CORS_ORIGIN === "string" ? [] : env.CORS_ORIGIN,
84
+ emailAndPassword: {
85
+ enabled: true,
86
+ // Set to true to block sign-in until the email address is verified
87
+ requireEmailVerification: false,
88
+ // A reset signs the user out everywhere
89
+ revokeSessionsOnPasswordReset: true,
90
+ sendResetPassword: ({ user, url, token }) => {
91
+ deliver({
92
+ to: user.email,
93
+ subject: "Reset your password",
94
+ text: `Open this link to choose a new password:\n${url}\n\nOr send this token to POST /api/auth/reset-password: ${token}`,
95
+ });
96
+ return Promise.resolve();
97
+ },
98
+ },
99
+ emailVerification: {
100
+ sendOnSignUp: true,
101
+ autoSignInAfterVerification: true,
102
+ sendVerificationEmail: ({ user, url }) => {
103
+ deliver({
104
+ to: user.email,
105
+ subject: "Verify your email address",
106
+ text: `Open this link to verify your email address:\n${withoutDefaultCallback(url)}`,
107
+ });
108
+ return Promise.resolve();
109
+ },
110
+ },
111
+ // Each provider is turned on once its client ID and secret are set
112
+ socialProviders: {
113
+ ...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
114
+ ? {
115
+ github: {
116
+ clientId: env.GITHUB_CLIENT_ID,
117
+ clientSecret: env.GITHUB_CLIENT_SECRET,
118
+ },
119
+ }
120
+ : {}),
121
+ ...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
122
+ ? {
123
+ google: {
124
+ clientId: env.GOOGLE_CLIENT_ID,
125
+ clientSecret: env.GOOGLE_CLIENT_SECRET,
126
+ },
127
+ }
128
+ : {}),
129
+ },
130
+ // Clients without cookies send "Authorization: Bearer <token>"; the token
131
+ // comes back in the set-auth-token header of sign-in and sign-up
132
+ plugins: [bearer()],
133
+ {{#if useRateLimit}}
134
+ // Sign-up, sign-in, and password reset: 10 attempts per client IP in 15
135
+ // minutes, against password guessing
136
+ rateLimit: {
137
+ enabled: true,
138
+ window: 15 * 60,
139
+ max: 100,
140
+ customRules: {
141
+ "/sign-in/*": { window: 15 * 60, max: 10 },
142
+ "/sign-up/*": { window: 15 * 60, max: 10 },
143
+ "/request-password-reset": { window: 15 * 60, max: 10 },
144
+ },
145
+ },
146
+ {{/if}}
147
+ advanced: {
148
+ // Set from req.ip on every request (see auth.module.ts)
149
+ ipAddress: { ipAddressHeaders: ["x-client-ip"] },
150
+ },
151
+ });
152
+ }
@@ -0,0 +1,37 @@
1
+ import { env } from "../env.js";
2
+
3
+ interface Email {
4
+ to: string;
5
+ subject: string;
6
+ text: string;
7
+ }
8
+
9
+ if (!env.RESEND_API_KEY && process.env.NODE_ENV === "production") {
10
+ console.warn(
11
+ "RESEND_API_KEY is not set: emails are printed to the log instead of sent"
12
+ );
13
+ }
14
+
15
+ // Sends through Resend when RESEND_API_KEY is set. Without it the email is
16
+ // printed, so verification and reset links can be copied from the terminal
17
+ export async function sendEmail({ to, subject, text }: Email): Promise<void> {
18
+ if (!env.RESEND_API_KEY) {
19
+ console.log(`\n--- Email to ${to}: ${subject}\n${text}\n---\n`);
20
+ return;
21
+ }
22
+
23
+ const response = await fetch("https://api.resend.com/emails", {
24
+ method: "POST",
25
+ headers: {
26
+ Authorization: `Bearer ${env.RESEND_API_KEY}`,
27
+ "Content-Type": "application/json",
28
+ },
29
+ body: JSON.stringify({ from: env.EMAIL_FROM, to, subject, text }),
30
+ });
31
+
32
+ if (!response.ok) {
33
+ throw new Error(
34
+ `Resend rejected the email (${response.status}): ${await response.text()}`
35
+ );
36
+ }
37
+ }
@@ -0,0 +1,15 @@
1
+ import { Controller, Get } from "@nestjs/common";
2
+ import { Session, type UserSession } from "@thallesp/nestjs-better-auth";
3
+
4
+ // Example of a protected route: the global auth guard answers 401 without a
5
+ // session cookie or an "Authorization: Bearer <token>" header
6
+ @Controller("api/me")
7
+ export class MeController {
8
+ @Get()
9
+ me(@Session() { user, session }: UserSession): {
10
+ user: UserSession["user"];
11
+ session: { expiresAt: Date };
12
+ } {
13
+ return { user, session: { expiresAt: session.expiresAt } };
14
+ }
15
+ }
@@ -1,4 +1,4 @@
1
- import { auth } from "@/lib/auth";
2
- import { toNextJsHandler } from "better-auth/next-js";
3
-
4
- export const { GET, POST } = toNextJsHandler(auth);
1
+ import { auth } from "@/lib/auth";
2
+ import { toNextJsHandler } from "better-auth/next-js";
3
+
4
+ export const { GET, POST } = toNextJsHandler(auth);