├── .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 |
2 |
3 |
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 |
37 | );
38 | }
39 |
40 | export function Submit() {
41 | const { pending } = useFormStatus();
42 |
43 | return (
44 |
45 | Add
46 |
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 | {
28 | startTransition(() => {
29 | onToggle(item.id);
30 | });
31 | }}
32 | >
33 | {item.completed ? "✅" : "☑️"}
34 |
35 | {item.description}
36 |
37 | {
40 | startTransition(() => {
41 | onRemove(item.id);
42 | });
43 | }}
44 | >
45 |
53 |
58 |
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/app/(authenticated)/header.tsx:
--------------------------------------------------------------------------------
1 | import { UserButton } from "./user-button";
2 |
3 | export function Header() {
4 | return (
5 |
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 |
19 |
23 |
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 | 
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 |
--------------------------------------------------------------------------------