saas-init 1.0.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 (46) hide show
  1. package/README.md +98 -0
  2. package/dist/index.js +765 -0
  3. package/package.json +68 -0
  4. package/templates/auth/clerk/app/sign-in/[[...sign-in]]/page.tsx +9 -0
  5. package/templates/auth/clerk/app/sign-up/[[...sign-up]]/page.tsx +9 -0
  6. package/templates/auth/clerk/middleware.ts +12 -0
  7. package/templates/auth/nextauth/app/api/auth/route.ts +3 -0
  8. package/templates/auth/nextauth/auth.ts +12 -0
  9. package/templates/auth/supabase/middleware.ts +59 -0
  10. package/templates/auth/supabase/utils/supabase/client.ts +12 -0
  11. package/templates/auth/supabase/utils/supabase/server.ts +35 -0
  12. package/templates/base/app/globals.css +87 -0
  13. package/templates/base/app/layout.tsx +19 -0
  14. package/templates/base/app/page.tsx +7 -0
  15. package/templates/base/components.json +21 -0
  16. package/templates/base/lib/utils.ts +6 -0
  17. package/templates/base/next.config.ts +7 -0
  18. package/templates/base/package.json +23 -0
  19. package/templates/base/postcss.config.mjs +5 -0
  20. package/templates/base/tsconfig.json +27 -0
  21. package/templates/database/postgres/db/index.ts +12 -0
  22. package/templates/database/postgres/db/schema.ts +7 -0
  23. package/templates/database/postgres/drizzle.config.ts +10 -0
  24. package/templates/database/sqlite/db/index.ts +7 -0
  25. package/templates/database/sqlite/db/schema.ts +7 -0
  26. package/templates/database/sqlite/drizzle.config.ts +10 -0
  27. package/templates/database/supabase/utils/supabase/client.ts +12 -0
  28. package/templates/database/supabase/utils/supabase/db.ts +10 -0
  29. package/templates/docker/.dockerignore +5 -0
  30. package/templates/docker/Dockerfile +25 -0
  31. package/templates/docker/docker-compose.yml +22 -0
  32. package/templates/email/postmark/lib/email.ts +17 -0
  33. package/templates/email/resend/lib/email.ts +20 -0
  34. package/templates/github/.github/workflows/ci.yml +55 -0
  35. package/templates/landing/app/page.tsx +21 -0
  36. package/templates/landing/components/Footer.tsx +37 -0
  37. package/templates/landing/components/Hero.tsx +29 -0
  38. package/templates/landing/components/ProblemAgitate.tsx +34 -0
  39. package/templates/landing/components/SecondaryCTA.tsx +20 -0
  40. package/templates/landing/components/SocialProof.tsx +43 -0
  41. package/templates/landing/components/Transformation.tsx +48 -0
  42. package/templates/landing/components/ValueStack.tsx +54 -0
  43. package/templates/payments/lemonsqueezy/app/api/webhooks/lemonsqueezy/route.ts +58 -0
  44. package/templates/payments/lemonsqueezy/lib/lemonsqueezy.ts +13 -0
  45. package/templates/payments/stripe/app/api/webhooks/stripe/route.ts +46 -0
  46. package/templates/payments/stripe/lib/stripe.ts +10 -0
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "saas-init",
3
+ "version": "1.0.0",
4
+ "description": "CLI scaffolding tool that generates production-ready SaaS projects with Next.js, auth, payments, database, and email — configured and ready to ship.",
5
+ "type": "module",
6
+ "bin": {
7
+ "saas-init": "./dist/index.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "files": [
11
+ "dist",
12
+ "templates"
13
+ ],
14
+ "dependencies": {
15
+ "@clack/prompts": "^0.9.1",
16
+ "commander": "^12.1.0",
17
+ "fs-extra": "^11.2.0",
18
+ "zod": "^3.23.8"
19
+ },
20
+ "devDependencies": {
21
+ "@types/fs-extra": "^11.0.4",
22
+ "@types/node": "^22.0.0",
23
+ "@typescript-eslint/parser": "^8.57.1",
24
+ "eslint": "^9.0.0",
25
+ "prettier": "^3.3.3",
26
+ "tsup": "^8.3.0",
27
+ "typescript": "^5.6.3",
28
+ "vitest": "^2.1.8"
29
+ },
30
+ "keywords": [
31
+ "saas",
32
+ "scaffold",
33
+ "cli",
34
+ "nextjs",
35
+ "starter",
36
+ "boilerplate",
37
+ "clerk",
38
+ "nextauth",
39
+ "supabase",
40
+ "stripe",
41
+ "drizzle",
42
+ "tailwind",
43
+ "shadcn"
44
+ ],
45
+ "author": "Oleg Koval",
46
+ "license": "MIT",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/olegkoval/saas-init.git"
50
+ },
51
+ "homepage": "https://github.com/olegkoval/saas-init#readme",
52
+ "bugs": {
53
+ "url": "https://github.com/olegkoval/saas-init/issues"
54
+ },
55
+ "engines": {
56
+ "node": ">=18"
57
+ },
58
+ "scripts": {
59
+ "build": "tsup",
60
+ "dev": "tsup --watch",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest",
63
+ "lint": "eslint src tests",
64
+ "format": "prettier --write .",
65
+ "format:check": "prettier --check .",
66
+ "typecheck": "tsc --noEmit"
67
+ }
68
+ }
@@ -0,0 +1,9 @@
1
+ import { SignIn } from '@clerk/nextjs'
2
+
3
+ export default function SignInPage() {
4
+ return (
5
+ <main className="flex min-h-screen items-center justify-center">
6
+ <SignIn />
7
+ </main>
8
+ )
9
+ }
@@ -0,0 +1,9 @@
1
+ import { SignUp } from '@clerk/nextjs'
2
+
3
+ export default function SignUpPage() {
4
+ return (
5
+ <main className="flex min-h-screen items-center justify-center">
6
+ <SignUp />
7
+ </main>
8
+ )
9
+ }
@@ -0,0 +1,12 @@
1
+ import { clerkMiddleware } from '@clerk/nextjs/server'
2
+
3
+ export default clerkMiddleware()
4
+
5
+ export const config = {
6
+ matcher: [
7
+ // Skip Next.js internals and all static files, unless found in search params
8
+ '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
9
+ // Always run for API routes
10
+ '/(api|trpc)(.*)',
11
+ ],
12
+ }
@@ -0,0 +1,3 @@
1
+ import { handlers } from '@/auth'
2
+
3
+ export const { GET, POST } = handlers
@@ -0,0 +1,12 @@
1
+ import NextAuth from 'next-auth'
2
+
3
+ export const { handlers, signIn, signOut, auth } = NextAuth({
4
+ providers: [
5
+ // Add your providers here
6
+ // Example:
7
+ // GitHub({
8
+ // clientId: process.env.GITHUB_ID!,
9
+ // clientSecret: process.env.GITHUB_SECRET!,
10
+ // }),
11
+ ],
12
+ })
@@ -0,0 +1,59 @@
1
+ import { createServerClient } from '@supabase/ssr'
2
+ import { NextResponse, type NextRequest } from 'next/server'
3
+
4
+ export async function updateSession(request: NextRequest) {
5
+ let supabaseResponse = NextResponse.next({
6
+ request,
7
+ })
8
+
9
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL
10
+ const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
11
+
12
+ if (!url || !anonKey) {
13
+ throw new Error('Missing required Supabase environment variables: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY')
14
+ }
15
+
16
+ const supabase = createServerClient(
17
+ url,
18
+ anonKey,
19
+ {
20
+ cookies: {
21
+ getAll() {
22
+ return request.cookies.getAll()
23
+ },
24
+ setAll(cookiesToSet) {
25
+ cookiesToSet.forEach(({ name, value }) =>
26
+ request.cookies.set(name, value)
27
+ )
28
+ supabaseResponse = NextResponse.next({
29
+ request,
30
+ })
31
+ cookiesToSet.forEach(({ name, value, options }) =>
32
+ supabaseResponse.cookies.set(name, value, options)
33
+ )
34
+ },
35
+ },
36
+ }
37
+ )
38
+
39
+ await supabase.auth.getUser()
40
+
41
+ return supabaseResponse
42
+ }
43
+
44
+ export default async function middleware(request: NextRequest) {
45
+ try {
46
+ return await updateSession(request)
47
+ } catch (error) {
48
+ // Return 500 error response instead of throwing to avoid middleware crash
49
+ return new NextResponse('Internal Server Error: Missing Supabase configuration', {
50
+ status: 500,
51
+ })
52
+ }
53
+ }
54
+
55
+ export const config = {
56
+ matcher: [
57
+ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
58
+ ],
59
+ }
@@ -0,0 +1,12 @@
1
+ import { createBrowserClient } from '@supabase/ssr'
2
+
3
+ export function createClient() {
4
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL
5
+ const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
6
+
7
+ if (!url || !anonKey) {
8
+ throw new Error('Missing required Supabase environment variables: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY')
9
+ }
10
+
11
+ return createBrowserClient(url, anonKey)
12
+ }
@@ -0,0 +1,35 @@
1
+ import { createServerClient } from '@supabase/ssr'
2
+ import { cookies } from 'next/headers'
3
+
4
+ export async function createClient() {
5
+ const cookieStore = await cookies()
6
+
7
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL
8
+ const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
9
+
10
+ if (!url || !anonKey) {
11
+ throw new Error('Missing required Supabase environment variables: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY')
12
+ }
13
+
14
+ return createServerClient(
15
+ url,
16
+ anonKey,
17
+ {
18
+ cookies: {
19
+ getAll() {
20
+ return cookieStore.getAll()
21
+ },
22
+ setAll(cookiesToSet) {
23
+ try {
24
+ cookiesToSet.forEach(({ name, value, options }) =>
25
+ cookieStore.set(name, value, options)
26
+ )
27
+ } catch {
28
+ // setAll called from a Server Component — can be ignored if
29
+ // session refresh middleware is in place
30
+ }
31
+ },
32
+ },
33
+ }
34
+ )
35
+ }
@@ -0,0 +1,87 @@
1
+ @import "tailwindcss";
2
+
3
+ @theme inline {
4
+ --color-background: var(--background);
5
+ --color-foreground: var(--foreground);
6
+ --color-card: var(--card);
7
+ --color-card-foreground: var(--card-foreground);
8
+ --color-popover: var(--popover);
9
+ --color-popover-foreground: var(--popover-foreground);
10
+ --color-primary: var(--primary);
11
+ --color-primary-foreground: var(--primary-foreground);
12
+ --color-secondary: var(--secondary);
13
+ --color-secondary-foreground: var(--secondary-foreground);
14
+ --color-muted: var(--muted);
15
+ --color-muted-foreground: var(--muted-foreground);
16
+ --color-accent: var(--accent);
17
+ --color-accent-foreground: var(--accent-foreground);
18
+ --color-destructive: var(--destructive);
19
+ --color-border: var(--border);
20
+ --color-input: var(--input);
21
+ --color-ring: var(--ring);
22
+ --radius-sm: calc(var(--radius) - 4px);
23
+ --radius-md: calc(var(--radius) - 2px);
24
+ --radius-lg: var(--radius);
25
+ --radius-xl: calc(var(--radius) + 4px);
26
+ }
27
+
28
+ :root {
29
+ --background: oklch(1 0 0);
30
+ --foreground: oklch(0.145 0 0);
31
+ --card: oklch(1 0 0);
32
+ --card-foreground: oklch(0.145 0 0);
33
+ --popover: oklch(1 0 0);
34
+ --popover-foreground: oklch(0.145 0 0);
35
+ --primary: oklch(0.205 0 0);
36
+ --primary-foreground: oklch(0.985 0 0);
37
+ --secondary: oklch(0.97 0 0);
38
+ --secondary-foreground: oklch(0.205 0 0);
39
+ --muted: oklch(0.97 0 0);
40
+ --muted-foreground: oklch(0.556 0 0);
41
+ --accent: oklch(0.97 0 0);
42
+ --accent-foreground: oklch(0.205 0 0);
43
+ --destructive: oklch(0.577 0.245 27.325);
44
+ --border: oklch(0.922 0 0);
45
+ --input: oklch(0.922 0 0);
46
+ --ring: oklch(0.708 0 0);
47
+ --radius: 0.625rem;
48
+ }
49
+
50
+ .dark {
51
+ --background: oklch(0.145 0 0);
52
+ --foreground: oklch(0.985 0 0);
53
+ --card: oklch(0.205 0 0);
54
+ --card-foreground: oklch(0.985 0 0);
55
+ --popover: oklch(0.205 0 0);
56
+ --popover-foreground: oklch(0.985 0 0);
57
+ --primary: oklch(0.922 0 0);
58
+ --primary-foreground: oklch(0.205 0 0);
59
+ --secondary: oklch(0.269 0 0);
60
+ --secondary-foreground: oklch(0.985 0 0);
61
+ --muted: oklch(0.269 0 0);
62
+ --muted-foreground: oklch(0.708 0 0);
63
+ --accent: oklch(0.269 0 0);
64
+ --accent-foreground: oklch(0.985 0 0);
65
+ --destructive: oklch(0.704 0.191 22.216);
66
+ --border: oklch(1 0 0 / 10%);
67
+ --input: oklch(1 0 0 / 15%);
68
+ --ring: oklch(0.556 0 0);
69
+ }
70
+
71
+ * {
72
+ box-sizing: border-box;
73
+ padding: 0;
74
+ margin: 0;
75
+ }
76
+
77
+ html,
78
+ body {
79
+ max-width: 100vw;
80
+ overflow-x: hidden;
81
+ }
82
+
83
+ body {
84
+ background: var(--background);
85
+ color: var(--foreground);
86
+ font-family: Arial, Helvetica, sans-serif;
87
+ }
@@ -0,0 +1,19 @@
1
+ import type { Metadata } from 'next'
2
+ import './globals.css'
3
+
4
+ export const metadata: Metadata = {
5
+ title: '{{name}}',
6
+ description: 'Generated by saas-init',
7
+ }
8
+
9
+ export default function RootLayout({
10
+ children,
11
+ }: {
12
+ children: React.ReactNode
13
+ }) {
14
+ return (
15
+ <html lang="en">
16
+ <body>{children}</body>
17
+ </html>
18
+ )
19
+ }
@@ -0,0 +1,7 @@
1
+ export default function Home() {
2
+ return (
3
+ <main>
4
+ <h1>Welcome to your new app</h1>
5
+ </main>
6
+ )
7
+ }
@@ -0,0 +1,21 @@
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": "zinc",
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
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from 'next'
2
+
3
+ const nextConfig: NextConfig = {
4
+ output: 'standalone',
5
+ }
6
+
7
+ export default nextConfig
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint",
10
+ "test": "echo \"No tests configured\" && exit 0"
11
+ },
12
+ "dependencies": {
13
+ "next": "^15.0.0",
14
+ "react": "^19.0.0",
15
+ "react-dom": "^19.0.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^22.0.0",
19
+ "@types/react": "^19.0.0",
20
+ "@types/react-dom": "^19.0.0",
21
+ "typescript": "^5.0.0"
22
+ }
23
+ }
@@ -0,0 +1,5 @@
1
+ export default {
2
+ plugins: {
3
+ '@tailwindcss/postcss': {},
4
+ },
5
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./*"]
23
+ }
24
+ },
25
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26
+ "exclude": ["node_modules"]
27
+ }
@@ -0,0 +1,12 @@
1
+ import { drizzle } from 'drizzle-orm/postgres-js'
2
+ import postgres from 'postgres'
3
+ import * as schema from './schema'
4
+
5
+ const databaseUrl = process.env.DATABASE_URL
6
+ if (!databaseUrl) {
7
+ throw new Error('Missing required environment variable: DATABASE_URL')
8
+ }
9
+
10
+ const client = postgres(databaseUrl)
11
+
12
+ export const db = drizzle(client, { schema })
@@ -0,0 +1,7 @@
1
+ import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'
2
+
3
+ export const users = pgTable('users', {
4
+ id: text('id').primaryKey(),
5
+ email: text('email').notNull().unique(),
6
+ createdAt: timestamp('created_at').defaultNow().notNull(),
7
+ })
@@ -0,0 +1,10 @@
1
+ import type { Config } from 'drizzle-kit'
2
+
3
+ export default {
4
+ schema: './db/schema.ts',
5
+ out: './drizzle',
6
+ dialect: 'postgresql',
7
+ dbCredentials: {
8
+ url: process.env.DATABASE_URL!,
9
+ },
10
+ } satisfies Config
@@ -0,0 +1,7 @@
1
+ import { drizzle } from 'drizzle-orm/better-sqlite3'
2
+ import Database from 'better-sqlite3'
3
+ import * as schema from './schema'
4
+
5
+ const client = new Database('./local.db')
6
+
7
+ export const db = drizzle(client, { schema })
@@ -0,0 +1,7 @@
1
+ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
2
+
3
+ export const users = sqliteTable('users', {
4
+ id: text('id').primaryKey(),
5
+ email: text('email').notNull().unique(),
6
+ createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()).notNull(),
7
+ })
@@ -0,0 +1,10 @@
1
+ import type { Config } from 'drizzle-kit'
2
+
3
+ export default {
4
+ schema: './db/schema.ts',
5
+ out: './drizzle',
6
+ dialect: 'sqlite',
7
+ dbCredentials: {
8
+ url: './local.db',
9
+ },
10
+ } satisfies Config
@@ -0,0 +1,12 @@
1
+ import { createClient } from '@supabase/supabase-js'
2
+
3
+ export function createBrowserClient() {
4
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL
5
+ const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
6
+
7
+ if (!url || !anonKey) {
8
+ throw new Error('Missing required Supabase environment variables: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY')
9
+ }
10
+
11
+ return createClient(url, anonKey)
12
+ }
@@ -0,0 +1,10 @@
1
+ import { createClient } from '@supabase/supabase-js'
2
+
3
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL
4
+ const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
5
+
6
+ if (!url || !anonKey) {
7
+ throw new Error('Missing required Supabase environment variables: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY')
8
+ }
9
+
10
+ export const supabase = createClient(url, anonKey)
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ .next
3
+ .git
4
+ .env.local
5
+ *.test.*
@@ -0,0 +1,25 @@
1
+ # Stage 1: Install dependencies
2
+ FROM node:20-alpine AS deps
3
+ RUN corepack enable
4
+ WORKDIR /app
5
+ COPY package.json pnpm-lock.yaml ./
6
+ RUN pnpm install --frozen-lockfile
7
+
8
+ # Stage 2: Build the application
9
+ FROM node:20-alpine AS builder
10
+ RUN corepack enable
11
+ WORKDIR /app
12
+ COPY --from=deps /app/node_modules ./node_modules
13
+ COPY . .
14
+ RUN pnpm build
15
+
16
+ # Stage 3: Production runner
17
+ FROM node:20-alpine AS runner
18
+ LABEL app="{{name}}"
19
+ WORKDIR /app
20
+ ENV NODE_ENV=production
21
+ COPY --from=builder /app/public ./public
22
+ COPY --from=builder /app/.next/standalone ./
23
+ COPY --from=builder /app/.next/static ./.next/static
24
+ EXPOSE 3000
25
+ CMD ["node", "server.js"]
@@ -0,0 +1,22 @@
1
+ services:
2
+ {{name}}:
3
+ build: .
4
+ ports:
5
+ - "3000:3000"
6
+ env_file:
7
+ - .env.local
8
+
9
+ # Uncomment to add a Postgres database:
10
+ # postgres:
11
+ # image: postgres:16-alpine
12
+ # environment:
13
+ # POSTGRES_USER: postgres
14
+ # POSTGRES_PASSWORD: password
15
+ # POSTGRES_DB: {{name}}
16
+ # ports:
17
+ # - "5432:5432"
18
+ # volumes:
19
+ # - postgres_data:/var/lib/postgresql/data
20
+
21
+ # volumes:
22
+ # postgres_data:
@@ -0,0 +1,17 @@
1
+ import * as postmark from 'postmark'
2
+
3
+ const apiToken = process.env.POSTMARK_API_TOKEN
4
+ if (!apiToken) {
5
+ throw new Error('Missing required environment variable: POSTMARK_API_TOKEN')
6
+ }
7
+
8
+ const client = new postmark.ServerClient(apiToken)
9
+
10
+ export async function sendEmail(to: string, subject: string, html: string): Promise<void> {
11
+ await client.sendEmail({
12
+ From: 'noreply@example.com',
13
+ To: to,
14
+ Subject: subject,
15
+ HtmlBody: html,
16
+ })
17
+ }
@@ -0,0 +1,20 @@
1
+ import { Resend } from 'resend'
2
+
3
+ const apiKey = process.env.RESEND_API_KEY
4
+ if (!apiKey) {
5
+ throw new Error('Missing required environment variable: RESEND_API_KEY')
6
+ }
7
+
8
+ const resend = new Resend(apiKey)
9
+
10
+ export async function sendEmail(to: string, subject: string, html: string): Promise<void> {
11
+ const { error } = await resend.emails.send({
12
+ from: 'noreply@example.com',
13
+ to,
14
+ subject,
15
+ html,
16
+ })
17
+ if (error) {
18
+ throw new Error(`Failed to send email: ${error.message}`)
19
+ }
20
+ }