├── .eslintrc.json ├── app ├── globals.css ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── opengraph-image.png ├── (unauthenticated) │ ├── sign-in │ │ └── [[...sign-in]] │ │ │ └── page.tsx │ ├── sign-up │ │ └── [[...sign-up]] │ │ │ └── page.tsx │ └── layout.tsx ├── (authenticated) │ ├── dashboard │ │ ├── todos.tsx │ │ ├── page.tsx │ │ ├── actions.ts │ │ ├── form.tsx │ │ ├── todo.tsx │ │ └── todo-list.tsx │ ├── layout.tsx │ ├── dump.sql │ │ └── route.ts │ ├── header.tsx │ └── user-button.tsx ├── layout.tsx ├── welcome │ └── route.tsx ├── turso.svg ├── webhooks │ └── clerk │ │ └── route.ts ├── utils.ts └── page.tsx ├── next.config.mjs ├── envConfig.ts ├── postcss.config.mjs ├── migrations ├── 0000_nappy_cammi.sql └── meta │ ├── _journal.json │ └── 0000_snapshot.json ├── renovate.json ├── middleware.ts ├── dump.sql ├── db └── schema.ts ├── tailwind.config.ts ├── .env.example ├── .gitignore ├── tsconfig.json ├── drizzle.config.ts ├── package.json └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notrab/turso-per-user-starter/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /app/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notrab/turso-per-user-starter/HEAD/app/favicon-16x16.png -------------------------------------------------------------------------------- /app/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notrab/turso-per-user-starter/HEAD/app/favicon-32x32.png -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notrab/turso-per-user-starter/HEAD/app/opengraph-image.png -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /envConfig.ts: -------------------------------------------------------------------------------- 1 | import { loadEnvConfig } from "@next/env"; 2 | 3 | const projectDir = process.cwd(); 4 | loadEnvConfig(projectDir); 5 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /migrations/0000_nappy_cammi.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `todos` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `description` text NOT NULL, 4 | `completed` integer DEFAULT false NOT NULL 5 | ); 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "schedule": ["before 4am on the first day of the month"] 5 | } 6 | -------------------------------------------------------------------------------- /app/(unauthenticated)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(unauthenticated)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { clerkMiddleware } from "@clerk/nextjs/server"; 2 | 3 | export default clerkMiddleware(); 4 | 5 | export const config = { 6 | matcher: ["/((?!.+.[w]+$|_next).*)", "/", "/(api|trpc)(.*)"], 7 | }; 8 | -------------------------------------------------------------------------------- /dump.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=OFF; 2 | BEGIN TRANSACTION; 3 | 4 | CREATE TABLE todos ( 5 | id INTEGER PRIMARY KEY AUTOINCREMENT, 6 | description TEXT, 7 | completed integer DEFAULT false NOT NULL 8 | ); 9 | 10 | COMMIT; 11 | -------------------------------------------------------------------------------- /app/(unauthenticated)/layout.tsx: -------------------------------------------------------------------------------- 1 | export default async function Layout({ 2 | children, 3 | }: Readonly<{ 4 | children: React.ReactNode; 5 | }>) { 6 | return ( 7 |
{children}
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1723796328018, 9 | "tag": "0000_nappy_cammi", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /db/schema.ts: -------------------------------------------------------------------------------- 1 | import { text, integer, sqliteTable } from "drizzle-orm/sqlite-core"; 2 | 3 | export const todos = sqliteTable("todos", { 4 | id: integer("id", { 5 | mode: "number", 6 | }).primaryKey({ autoIncrement: true }), 7 | description: text("description").notNull(), 8 | completed: integer("completed", { mode: "boolean" }).notNull().default(false), 9 | }); 10 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: ["./app/**/*.{js,ts,jsx,tsx,mdx}"], 5 | theme: { 6 | extend: { 7 | colors: { 8 | aquamarine: "#4ff8d2", 9 | "rich-black": "#011618", 10 | "brunswick-green": "#154F47", 11 | }, 12 | }, 13 | }, 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /app/(authenticated)/dashboard/todos.tsx: -------------------------------------------------------------------------------- 1 | import { getDatabaseClient } from "@/app/utils"; 2 | import { TodoList } from "./todo-list"; 3 | 4 | export async function Todos() { 5 | const client = await getDatabaseClient(); 6 | 7 | if (!client) { 8 | return

Database not ready

; 9 | } 10 | 11 | const todos = await client.query.todos.findMany(); 12 | 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /app/(authenticated)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { Todos } from "./todos"; 2 | 3 | export default async function Page() { 4 | return ( 5 |
6 |
7 |

Todos

8 |

9 | The todos you add below are created inside your own database. 10 |

11 |
12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Sign up to Clerk 2 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 3 | CLERK_SECRET_KEY= 4 | 5 | # Create a new Clerk webhook (user.created event) with your app URL and append /webhooks/clerk 6 | # Set the webhook secret below provided by Clerk 7 | CLERK_WEBHOOK_SECRET= 8 | 9 | # turso auth api-tokens mint clerk 10 | TURSO_API_TOKEN= 11 | 12 | # your personal or organization name 13 | TURSO_ORG= 14 | 15 | # turso db create [database-name] 16 | TURSO_DATABASE_NAME= 17 | 18 | # turso group tokens create 19 | TURSO_GROUP_AUTH_TOKEN= 20 | -------------------------------------------------------------------------------- /app/(authenticated)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { checkDatabaseExists } from "../utils"; 4 | import { Header } from "./header"; 5 | 6 | export default async function Layout({ 7 | children, 8 | }: Readonly<{ 9 | children: React.ReactNode; 10 | }>) { 11 | const databaseExists = await checkDatabaseExists(); 12 | 13 | if (!databaseExists) redirect("/welcome"); 14 | 15 | return ( 16 | <> 17 |
18 |
{children}
19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .env 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import "./envConfig"; 2 | 3 | import { defineConfig } from "drizzle-kit"; 4 | 5 | if (!process.env.TURSO_DATABASE_NAME) { 6 | throw new Error("TURSO_DATABASE_NAME is missing"); 7 | } 8 | 9 | if (!process.env.TURSO_ORG) { 10 | throw new Error("TURSO_ORG is missing"); 11 | } 12 | 13 | if (!process.env.TURSO_GROUP_AUTH_TOKEN) { 14 | throw new Error("TURSO_GROUP_AUTH_TOKEN is missing"); 15 | } 16 | 17 | const url = `libsql://${process.env.TURSO_DATABASE_NAME}-${process.env.TURSO_ORG}.turso.io`; 18 | 19 | export default defineConfig({ 20 | schema: "./db/schema.ts", 21 | out: "./migrations", 22 | dialect: "turso", 23 | dbCredentials: { 24 | url, 25 | authToken: process.env.TURSO_GROUP_AUTH_TOKEN, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import { ClerkProvider } from "@clerk/nextjs"; 4 | 5 | import "./globals.css"; 6 | 7 | const inter = Inter({ subsets: ["latin"], display: "swap" }); 8 | 9 | export const metadata: Metadata = { 10 | title: "Turso Per User Starter", 11 | description: "Database per user starter with Turso, Clerk, and SQLite", 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | return ( 20 | 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/(authenticated)/dump.sql/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs/server"; 2 | 3 | import { getDumpUrl } from "@/app/utils"; 4 | 5 | export async function GET() { 6 | auth().protect(); 7 | 8 | const url = getDumpUrl(); 9 | 10 | if (!url) return new Response("No data yet"); 11 | 12 | try { 13 | const response = await fetch(url, { 14 | headers: { 15 | Authorization: `Bearer ${process.env.TURSO_GROUP_AUTH_TOKEN}`, 16 | }, 17 | }); 18 | 19 | if (response.ok) { 20 | const text = await response.text(); 21 | 22 | return new Response(text); 23 | } 24 | 25 | return new Response("No data yet"); 26 | } catch (err) { 27 | console.log("Could not download dump"); 28 | return new Response("Could not download dump", { status: 500 }); 29 | } 30 | } 31 | 32 | export const revalidate = 0; 33 | -------------------------------------------------------------------------------- /app/welcome/route.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs/server"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import { checkDatabaseExists, createUserDatabase } from "../utils"; 5 | 6 | export async function GET() { 7 | const { userId } = auth().protect(); 8 | 9 | const databaseExists = await checkDatabaseExists(); 10 | 11 | if (databaseExists) { 12 | return redirect("/dashboard"); 13 | } 14 | 15 | if (!userId) { 16 | return redirect("/sign-in"); 17 | } 18 | 19 | try { 20 | const success = await createUserDatabase(userId); 21 | 22 | if (!success) { 23 | return new Response("Error creating database", { 24 | status: 500, 25 | }); 26 | } 27 | } catch (err) { 28 | console.error("Error creating database:", err); 29 | return new Response("Error occurred", { 30 | status: 500, 31 | }); 32 | } 33 | 34 | redirect("/dashboard"); 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "turso-platforms-starter", 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 | "db:generate": "drizzle-kit generate", 11 | "db:migrate": "drizzle-kit migrate", 12 | "db:studio": "drizzle-kit studio" 13 | }, 14 | "dependencies": { 15 | "@clerk/nextjs": "^5.3.3", 16 | "@libsql/client": "^0.14.0", 17 | "@next/env": "^14.2.11", 18 | "@tursodatabase/api": "^1.8.1", 19 | "drizzle-orm": "^0.35.1", 20 | "md5": "^2.3.0", 21 | "next": "^14.2.11", 22 | "react": "^18.3.1", 23 | "react-dom": "^18.3.1", 24 | "svix": "^1.34.0" 25 | }, 26 | "devDependencies": { 27 | "@types/md5": "^2.3.5", 28 | "@types/node": "^22.5.5", 29 | "@types/react": "^18.3.6", 30 | "@types/react-dom": "^18", 31 | "drizzle-kit": "^0.26.2", 32 | "eslint": "^8.57.1", 33 | "eslint-config-next": "^14.2.11", 34 | "postcss": "^8.4.47", 35 | "tailwindcss": "^3.4.11", 36 | "typescript": "^5.6.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/turso.svg: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "2eaf9f90-6492-4869-9389-d42d724ffb32", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "todos": { 8 | "name": "todos", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "description": { 18 | "name": "description", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "completed": { 25 | "name": "completed", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false, 30 | "default": false 31 | } 32 | }, 33 | "indexes": {}, 34 | "foreignKeys": {}, 35 | "compositePrimaryKeys": {}, 36 | "uniqueConstraints": {} 37 | } 38 | }, 39 | "enums": {}, 40 | "_meta": { 41 | "schemas": {}, 42 | "tables": {}, 43 | "columns": {} 44 | }, 45 | "internal": { 46 | "indexes": {} 47 | } 48 | } -------------------------------------------------------------------------------- /app/(authenticated)/dashboard/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | import * as schema from "@/db/schema"; 5 | import { getDatabaseClient } from "@/app/utils"; 6 | import { eq, sql } from "drizzle-orm"; 7 | 8 | export type TodoItem = { 9 | id: number; 10 | description: string; 11 | }; 12 | 13 | export const addTodo = async (formData: FormData) => { 14 | const client = await getDatabaseClient(); 15 | 16 | const description = formData.get("description") as string; 17 | 18 | if (!client) return null; 19 | 20 | await client.insert(schema.todos).values({ 21 | description, 22 | }); 23 | 24 | revalidatePath("/dashboard"); 25 | }; 26 | 27 | export const removeTodo = async (id: number) => { 28 | const client = await getDatabaseClient(); 29 | 30 | if (!client) return null; 31 | 32 | await client.delete(schema.todos).where(eq(schema.todos.id, id)); 33 | 34 | revalidatePath("/dashboard"); 35 | }; 36 | 37 | export const toggleTodo = async (id: number) => { 38 | const client = await getDatabaseClient(); 39 | 40 | if (!client) return null; 41 | 42 | await client 43 | .update(schema.todos) 44 | .set({ completed: sql`NOT completed` }) 45 | .where(eq(schema.todos.id, id)); 46 | 47 | revalidatePath("/dashboard"); 48 | }; 49 | -------------------------------------------------------------------------------- /app/(authenticated)/dashboard/form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRef } from "react"; 4 | import { useFormStatus } from "react-dom"; 5 | 6 | import { addTodo } from "./actions"; 7 | 8 | export function Form({ onSubmit }: { onSubmit: (formData: FormData) => void }) { 9 | const formRef = useRef(null); 10 | 11 | const handleSubmit = async (formData: FormData) => { 12 | await onSubmit(formData); 13 | formRef.current?.reset(); 14 | }; 15 | 16 | return ( 17 |
22 |
23 | ☑️ 24 | 34 |
35 | 36 | 37 | ); 38 | } 39 | 40 | export function Submit() { 41 | const { pending } = useFormStatus(); 42 | 43 | return ( 44 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /app/(authenticated)/dashboard/todo.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTransition } from "react"; 4 | import { InferSelectModel } from "drizzle-orm"; 5 | 6 | import * as schema from "@/db/schema"; 7 | import { removeTodo, toggleTodo } from "./actions"; 8 | 9 | type Todo = InferSelectModel; 10 | 11 | export function Todo({ 12 | item, 13 | onRemove, 14 | onToggle, 15 | }: { 16 | item: Todo; 17 | onRemove: (id: number) => void; 18 | onToggle: (id: number) => void; 19 | }) { 20 | const [_, startTransition] = useTransition(); 21 | 22 | return ( 23 |
  • 24 |
    25 | 35 | {item.description} 36 |
    37 | 60 |
  • 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /app/(authenticated)/header.tsx: -------------------------------------------------------------------------------- 1 | import { UserButton } from "./user-button"; 2 | 3 | export function Header() { 4 | return ( 5 |
    6 |
    7 |
    8 |
    9 |
    10 | 22 |
    23 |
    24 | 25 | 26 |
    27 |
    28 |
    29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/(authenticated)/dashboard/todo-list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useOptimistic } from "react"; 4 | import { InferSelectModel } from "drizzle-orm"; 5 | 6 | import * as schema from "@/db/schema"; 7 | import { Todo } from "./todo"; 8 | import { Form } from "./form"; 9 | import { addTodo, removeTodo, toggleTodo } from "./actions"; 10 | 11 | type Todo = InferSelectModel; 12 | 13 | export function TodoList({ initialTodos }: { initialTodos: Todo[] }) { 14 | const [optimisticTodos, addOptimisticTodo] = useOptimistic< 15 | Todo[], 16 | { action: "add" | "remove" | "toggle"; todo: Todo } 17 | >(initialTodos, (state, { action, todo }) => { 18 | switch (action) { 19 | case "add": 20 | return [...state, todo]; 21 | case "remove": 22 | return state.filter((t) => t.id !== todo.id); 23 | case "toggle": 24 | return state.map((t) => 25 | t.id === todo.id ? { ...t, completed: !t.completed } : t, 26 | ); 27 | } 28 | }); 29 | 30 | const handleAddTodo = async (formData: FormData) => { 31 | const description = formData.get("description") as string; 32 | const newTodo = { 33 | id: Date.now(), // Temporary ID 34 | description, 35 | completed: false, 36 | }; 37 | addOptimisticTodo({ action: "add", todo: newTodo }); 38 | await addTodo(formData); 39 | }; 40 | 41 | const handleRemoveTodo = async (id: number) => { 42 | addOptimisticTodo({ action: "remove", todo: { id } as Todo }); 43 | await removeTodo(id); 44 | }; 45 | 46 | const handleToggleTodo = async (id: number) => { 47 | addOptimisticTodo({ 48 | action: "toggle", 49 | todo: optimisticTodos.find((t) => t.id === id) as Todo, 50 | }); 51 | await toggleTodo(id); 52 | }; 53 | 54 | return ( 55 |
    56 | {optimisticTodos.map((todo) => ( 57 | 63 | ))} 64 |
    65 |
    66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /app/webhooks/clerk/route.ts: -------------------------------------------------------------------------------- 1 | import { Webhook } from "svix"; 2 | import { headers } from "next/headers"; 3 | import { WebhookEvent } from "@clerk/nextjs/server"; 4 | 5 | import { createUserDatabase } from "@/app/utils"; 6 | 7 | const allowedEvents = ["user.created"]; 8 | 9 | export async function POST(req: Request) { 10 | const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET; 11 | 12 | if (!WEBHOOK_SECRET) { 13 | throw new Error( 14 | "Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local", 15 | ); 16 | } 17 | 18 | const headerPayload = headers(); 19 | const svix_id = headerPayload.get("svix-id"); 20 | const svix_timestamp = headerPayload.get("svix-timestamp"); 21 | const svix_signature = headerPayload.get("svix-signature"); 22 | 23 | if (!svix_id || !svix_timestamp || !svix_signature) { 24 | return new Response("Error occurred -- no svix headers", { 25 | status: 400, 26 | }); 27 | } 28 | 29 | const payload = await req.json(); 30 | const body = JSON.stringify(payload); 31 | 32 | const wh = new Webhook(WEBHOOK_SECRET); 33 | 34 | let evt: WebhookEvent; 35 | 36 | try { 37 | evt = wh.verify(body, { 38 | "svix-id": svix_id, 39 | "svix-timestamp": svix_timestamp, 40 | "svix-signature": svix_signature, 41 | }) as WebhookEvent; 42 | } catch (err) { 43 | console.error("Error verifying webhook:", err); 44 | return new Response("Error occured", { 45 | status: 400, 46 | }); 47 | } 48 | 49 | const { id } = evt.data; 50 | const eventType = evt.type; 51 | 52 | if (!id) { 53 | return new Response("No ID found", { 54 | status: 400, 55 | }); 56 | } 57 | 58 | if (!allowedEvents.includes(eventType)) { 59 | return new Response("Event not allowed", { 60 | status: 400, 61 | }); 62 | } 63 | 64 | try { 65 | const success = await createUserDatabase(id); 66 | 67 | if (!success) { 68 | return new Response("Error creating database", { 69 | status: 500, 70 | }); 71 | } 72 | } catch (err) { 73 | console.error("Error processing webhook:", err); 74 | return new Response("Error occurred", { 75 | status: 500, 76 | }); 77 | } 78 | 79 | return new Response(); 80 | } 81 | -------------------------------------------------------------------------------- /app/utils.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs/server"; 2 | import { createClient as createLibsqlClient } from "@libsql/client"; 3 | import { createClient as createTursoClient } from "@tursodatabase/api"; 4 | import md5 from "md5"; 5 | import { redirect } from "next/navigation"; 6 | import { drizzle } from "drizzle-orm/libsql"; 7 | 8 | import * as schema from "@/db/schema"; 9 | 10 | const turso = createTursoClient({ 11 | token: process.env.TURSO_API_TOKEN!, 12 | org: process.env.TURSO_ORG!, 13 | }); 14 | 15 | export async function checkDatabaseExists(): Promise { 16 | const dbName = getDatabaseName(); 17 | 18 | if (!dbName) return false; 19 | 20 | try { 21 | await turso.databases.get(dbName); 22 | return true; 23 | } catch (error) { 24 | console.error("Error checking database existence:", error); 25 | return false; 26 | } 27 | } 28 | 29 | export async function getDatabaseClient() { 30 | const url = getLibsqlUrl(); 31 | 32 | if (!url) { 33 | console.error("Failed to create database client: URL is null."); 34 | return redirect("/welcome"); 35 | } 36 | 37 | try { 38 | const client = createLibsqlClient({ 39 | url, 40 | authToken: process.env.TURSO_GROUP_AUTH_TOKEN, 41 | }); 42 | 43 | return drizzle(client, { schema }); 44 | } catch (error) { 45 | console.error("Failed to create database client:", error); 46 | return null; 47 | } 48 | } 49 | 50 | export function getDatabaseName(): string | null { 51 | const userId = auth().userId; 52 | return userId ? md5(userId) : null; 53 | } 54 | 55 | function getDatabaseUrl(dbName: string | null): string | null { 56 | return dbName ? `${dbName}-${process.env.TURSO_ORG}.turso.io` : null; 57 | } 58 | 59 | function getLibsqlUrl(): string | null { 60 | const dbName = getDatabaseName(); 61 | const url = getDatabaseUrl(dbName); 62 | console.log({ url }); 63 | return url ? `libsql://${url}` : null; 64 | } 65 | 66 | export function getDumpUrl(): string | null { 67 | const dbName = getDatabaseName(); 68 | const url = getDatabaseUrl(dbName); 69 | return url ? `https://${url}/dump` : null; 70 | } 71 | 72 | export async function createUserDatabase(userId: string): Promise { 73 | if (!userId) return false; 74 | 75 | const dbName = md5(userId); 76 | 77 | try { 78 | await turso.databases.create(dbName, { 79 | group: "default", 80 | seed: { 81 | type: "database", 82 | name: process.env.TURSO_DATABASE_NAME!, 83 | }, 84 | }); 85 | 86 | return true; 87 | } catch (err) { 88 | console.error("Error creating user database:", err); 89 | return false; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { redirect } from "next/navigation"; 3 | import { auth } from "@clerk/nextjs/server"; 4 | 5 | export default function Page() { 6 | const { userId } = auth(); 7 | 8 | if (userId) return redirect("/dashboard"); 9 | 10 | return ( 11 |
    12 | 24 |

    25 | Turso Per User Starter 26 |

    27 |

    28 | Database per user demo —{" "} 29 | 30 | Sign up 31 | {" "} 32 | or{" "} 33 | 34 | login 35 | {" "} 36 |

    37 | 53 |
    54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /app/(authenticated)/user-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { UserButton as ClerkUserButton } from "@clerk/nextjs"; 4 | 5 | const DownloadIcon = () => { 6 | return ( 7 | 12 | 17 | 18 | ); 19 | }; 20 | 21 | const GithubIcon = () => { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export function UserButton() { 30 | return ( 31 |
    32 | 33 | 34 | } 36 | label="Download my data" 37 | href="/dump.sql" 38 | /> 39 | } 41 | label="Star repository" 42 | href="https://github.com/notrab/turso-per-user-starter" 43 | /> 44 | 45 | 46 |
    47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Turso Per User Starter 2 | 3 | A Next.js application that demonstrates how to use the [Turso](https://turso.tech) Platforms API to create a database per user. 4 | 5 | ![Turso Per User Starter Template](/app/opengraph-image.png) 6 | 7 | ## Demo 8 | 9 | The app below uses a database per user, and is powered by Turso. 10 | 11 | [https://turso-per-user-starter.vercel.app](https://turso-per-user-starter.vercel.app) 12 | 13 | ## Get Started 14 | 15 | Deploy your own Turso powered platform in a few easy steps... 16 | 17 | - [Create a Database](https://sqlite.new?dump=https%3A%2F%2Fraw.githubusercontent.com%2Fnotrab%2Fturso-per-user-starter%2Fmain%2Fdump.sql) 18 | 19 | - Once the database is created, you'll be presented with details about your database, and **Connect** details 20 | - Note down the following (you'll need these later): 21 | - Database name 22 | - Org name 23 | - Group Token (**Create Group Token** -> **Create Token**) 24 | - Platform API Token (**Create Platform API Token** -> **Insert memorable name** -> **Create Token**)) 25 | 26 | - [Sign up to Clerk](https://clerk.com) 27 | - Create a new application from the dashboard 28 | - Note down the following (you'll need these later): 29 | - Public key 30 | - Secret key 31 | - [Deploy with Vercel](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnotrab%2Fturso-per-user-starter&env=NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,CLERK_SECRET_KEY,TURSO_API_TOKEN,TURSO_ORG,TURSO_DATABASE_NAME,TURSO_GROUP_AUTH_TOKEN&demo-title=Turso%20Per%20User%20Starter&demo-description=Create%20a%20database%20per%20user&demo-image=https://raw.githubusercontent.com/notrab/turso-per-user-starter/28373b4c9c74f814e3749525ee3d53b603176834/app/opengraph-image.png&demo-url=https%3A%2F%2Fturso-per-user-starter.vercel.app) 32 | - Add the following environment variables (from the details you noted down earlier): 33 | - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` - Clerk public key 34 | - `CLERK_SECRET_KEY` - Clerk secret key 35 | - `TURSO_API_TOKEN` - Platform API Token 36 | - `TURSO_ORG` - Org name 37 | - `TURSO_DATABASE_NAME` - Database name 38 | - `TURSO_GROUP_AUTH_TOKEN` - Group Token 39 | - Click **Deploy** and you're done! 40 | 41 | _You may optionally set up webhooks to automate the creation of databases in the background — [learn more](https://github.com/notrab/turso-per-user-starter/wiki/Webhooks#using-webhooks-in-production)._ 42 | 43 | ## Local Development 44 | 45 | Start building your Turso powered platform in a few simple steps... 46 | 47 | 1.
    48 | Clone this repository 49 | 50 | Begin by cloning this repository to your machine: 51 | 52 | ```bash 53 | git clone https://github.com/notrab/turso-per-user-starter.git 54 | cd turso-per-user-starter 55 | ``` 56 | 57 |
    58 | 59 | 2.
    60 | Install dependencies and initialize .env 61 | 62 | Run the following: 63 | 64 | ```bash 65 | cp .env.example .env 66 | npm install 67 | ``` 68 | 69 |
    70 | 71 | 3.
    72 | Create a new Turso database with Turso 73 | 74 | Follow the instructions to install the [Turso CLI](https://docs.turso.tech/cli/installation), and then run the following: 75 | 76 | ```bash 77 | turso db create 78 | ``` 79 | 80 | > Alternatively, you can [sign up](https://app.turso.tech) on the web, and create a new database from there. 81 | 82 | Now update `.env` to include your organization, and database name: 83 | 84 | ```bash 85 | TURSO_ORG= 86 | TURSO_DATABASE_NAME= 87 | ``` 88 | 89 | > The `TURSO_ORG` can be your personal username, or the name of any organization you have with other users. 90 | 91 |
    92 | 93 | 4.
    94 | Create a new group token 95 | 96 | Run the following: 97 | 98 | ```bash 99 | turso group tokens create 100 | ``` 101 | 102 | Now update `.env` to include the group token: 103 | 104 | ```bash 105 | TURSO_GROUP_AUTH_TOKEN= 106 | ``` 107 | 108 | > If you didn't already have one, a new group will be created for you with the name `default`. 109 | 110 |
    111 | 112 | 5.
    113 | Run database migrations 114 | 115 | Run the following: 116 | 117 | ```bash 118 | npm run db:migrate 119 | ``` 120 | 121 | > If you make changes to `db/schema.ts`, make sure to run `npm run db:generate` to create the migrations, and `npm run db:migrate` to apply them. 122 | 123 |
    124 | 125 | 6.
    126 | Create a new Turso API Token 127 | 128 | Run the following: 129 | 130 | ```bash 131 | turso auth api-tokens mint clerk 132 | ``` 133 | 134 | Then set the API token in the `.env` file: 135 | 136 | ```bash 137 | TURSO_API_TOKEN= 138 | ``` 139 | 140 |
    141 | 142 | 7.
    143 | Configure Clerk 144 | 145 | [Sign up to Clerk](https://clerk.com) and create a new application. 146 | 147 | Add your Clerk public key and secret key to the `.env` file: 148 | 149 | ```bash 150 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 151 | CLERK_SECRET_KEY= 152 | ``` 153 | 154 |
    155 | 156 | 8.
    157 | Run the application 158 | 159 | Run the following: 160 | 161 | ```bash 162 | npm run dev 163 | ``` 164 | 165 | Now open [http://localhost:3000](http://localhost:3000) with your browser to try out the app! 166 | 167 |
    168 | 169 | ## Optional: Webhook setup 170 | 171 | You can automate the creation of databases per user in the background with webhooks. 172 | 173 | [Read the wiki](https://github.com/notrab/turso-per-user-starter/wiki/Webhooks#using-webhooks-locally) for more information on how to set up webhooks with Clerk during development, and production. 174 | 175 | ## Tech Stack 176 | 177 | - [Turso](https://turso.tech) for multi-tenant databases 178 | - [Next.js](https://nextjs.org) for powerful full stack apps 179 | - [Tailwind CSS](https://tailwindcss.com) for utility-first CSS 180 | - [Drizzle](https://orm.drizzle.team) for database migrations and ORM 181 | - [Clerk](https://clerk.com) for authentication 182 | - [Vercel](https://vercel.com) for hosting 183 | 184 | ## Need help? 185 | 186 | 1. Open an issue on GitHub 187 | 2. Submit a Pull Request to improve this repo 188 | 3. [Join us on Discord](https://tur.so/discord) 189 | --------------------------------------------------------------------------------