├── .env.example ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmrc ├── .prettierignore ├── .vscode ├── cspell.json ├── extensions.json └── settings.json ├── README.md ├── next-env.d.ts ├── next.config.js ├── package.json ├── patches └── @tanstack__react-query@4.14.5.patch ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── prisma └── schema.prisma ├── public └── favicon.ico ├── src ├── app │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ └── trpc │ │ │ └── [trpc] │ │ │ └── route.ts │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ ├── post │ │ └── [slug] │ │ │ └── page.tsx │ ├── posts │ │ └── create │ │ │ ├── create-post-form.tsx │ │ │ └── page.tsx │ └── profile │ │ └── page.tsx ├── auth │ ├── adapters │ │ └── kysely.ts │ ├── client │ │ └── index.ts │ ├── options.ts │ └── server │ │ └── index.ts ├── components │ ├── icons.tsx │ ├── main-dropdown-menu.tsx │ ├── main-nav │ │ ├── main-nav-inner.tsx │ │ └── main-nav.tsx │ ├── mobile-nav.tsx │ ├── posts-table.tsx │ ├── sign-in-options.tsx │ ├── theme-provider.tsx │ └── ui │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── hover-card.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── lib │ │ └── utils.ts │ │ ├── navigation-menu.tsx │ │ ├── popover.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ └── tooltip.tsx ├── config │ ├── docs.ts │ └── site.ts ├── lib │ └── kysely-db.ts ├── server │ ├── auth.ts │ ├── context.ts │ ├── env.js │ ├── routers │ │ ├── _app.ts │ │ └── example.ts │ └── trpc.ts ├── shared │ ├── hydration.ts │ ├── server-rsc │ │ ├── get-user.tsx │ │ └── trpc.ts │ └── utils.ts └── trpc │ ├── @trpc │ └── next-layout │ │ ├── create-hydrate-client.tsx │ │ ├── create-trpc-next-layout.ts │ │ ├── index.ts │ │ └── local-storage.ts │ └── client │ ├── hydrate-client.tsx │ └── trpc-client.tsx ├── tailwind.config.cjs └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo. 2 | # Keep this file up-to-date when you add new variables to \`.env\`. 3 | 4 | # This file will be committed to version control, so make sure not to have any secrets in it. 5 | # If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. 6 | 7 | # We use dotenv to load Prisma from Next.js' .env file 8 | # @see https://www.prisma.io/docs/reference/database-reference/connection-urls 9 | # You must use a Planetscale database. This repo relies on the @planetscale/database driver. 10 | DATABASE_URL= 11 | 12 | # @see https://next-auth.js.org/configuration/options#nextauth_url 13 | NEXTAUTH_URL='http://localhost:3000' 14 | 15 | # You can generate the secret via 'openssl rand -base64 32' on Unix 16 | # @see https://next-auth.js.org/configuration/options#secret 17 | # this is the production secret 18 | AUTH_SECRET='fDaMGatBRGilsXXhYn0+KWA6XO0ksfzTGZVzFcesB9M=' 19 | 20 | # @see https://next-auth.js.org/providers/discord 21 | # DISCORD_CLIENT_ID= 22 | # DISCORD_CLIENT_SECRET= 23 | 24 | GITHUB_ID= 25 | GITHUB_SECRET= 26 | GOOGLE_CLIENT_ID= 27 | GOOGLE_CLIENT_SECRET= 28 | 29 | NODE_VERSION=14 30 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /next.config.js 2 | /src/server/env.js 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", // Specifies the ESLint parser 3 | "extends": [ 4 | // 5 | "next/core-web-vitals", 6 | "plugin:@typescript-eslint/recommended" 7 | ], 8 | "parserOptions": { 9 | "project": "tsconfig.json", 10 | "ecmaVersion": 2018, // Allows for the parsing of modern ECMAScript features 11 | "sourceType": "module" // Allows for the use of imports 12 | }, 13 | "rules": { 14 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 15 | "@typescript-eslint/explicit-function-return-type": "off", 16 | "@typescript-eslint/explicit-module-boundary-types": "off", 17 | "react/react-in-jsx-scope": "off", 18 | "react/prop-types": "off", 19 | "@typescript-eslint/no-explicit-any": "off" 20 | }, 21 | "settings": { 22 | "react": { 23 | "version": "detect" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | .env 38 | 39 | /prisma/db.sqlite 40 | /prisma/db.splite-journal 41 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.vscode/cspell.json 2 | -------------------------------------------------------------------------------- /.vscode/cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "ignorePaths": [], 4 | "dictionaryDefinitions": [], 5 | "dictionaries": [], 6 | "words": [ 7 | "codegen", 8 | "middlewares", 9 | "paralleldrive" 10 | ], 11 | "ignoreWords": [ 12 | "authed", 13 | "ciphertext", 14 | "clsx", 15 | "doesn", 16 | "hasn", 17 | "kysely", 18 | "lucide", 19 | "nextauth", 20 | "nextjs", 21 | "pkce", 22 | "planetscale", 23 | "shadcn", 24 | "signin", 25 | "signout", 26 | "solidauth", 27 | "solidjs", 28 | "superjson", 29 | "tailwindcss", 30 | "tanstack", 31 | "trpc" 32 | ], 33 | "import": [] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "bradlc.vscode-tailwindcss", 6 | "Prisma.prisma" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "javascript.validate.enable": false, 3 | "typescript.validate.enable": true, 4 | "typescript.preferences.importModuleSpecifier": "relative", 5 | "typescript.updateImportsOnFileMove.enabled": "always", 6 | "typescript.tsdk": "./node_modules/typescript/lib", 7 | "editor.defaultFormatter": "esbenp.prettier-vscode", 8 | "[prisma]": { 9 | "editor.defaultFormatter": "Prisma.prisma" 10 | }, 11 | "typescript.suggest.completeFunctionCalls": true, 12 | "eslint.lintTask.enable": true, 13 | "typescript.surveys.enabled": false, 14 | "npm.autoDetect": "on", 15 | "git.inputValidationLength": 1000, 16 | "git.inputValidationSubjectLength": 100, 17 | "eslint.onIgnoredFiles": "off", 18 | "editor.tabSize": 2, 19 | "editor.detectIndentation": false, 20 | "editor.formatOnSave": true, 21 | "editor.codeActionsOnSave": { 22 | "source.organizeImports": true, 23 | "source.fixAll": true 24 | }, 25 | "files.watcherExclude": { 26 | ".git": true, 27 | "node_modules": true 28 | }, 29 | "typescript.enablePromptUseWorkspaceTsdk": true 30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Archived 2 | 3 | This repository has been superseded by [a repo](https://github.com/mattddean/t3-app-router-edge-drizzle) that replaces Prisma and Kysely with Drizzle ORM. 4 | 5 | >It's okay to use Prisma with Kysely, but having separate schema management and querying complicates things a bit. For example, if you add the `@updatedAt` flag to a column in Prisma, Prisma relies on its runtime to update the column rather than the database, but when querying with Kysely, Kysely will not automatically provide the a value for that column. But if you use Drizzle for schema management, specifying `.onUpdateNow()` on a column will cause the database to update this column for you on each update. 6 | 7 | # T3 App Router (Edge) 8 | 9 | An experimental attempt at using the fantastic T3 Stack entirely on the Edge runtime, with Next.js's beta App Router. 10 | 11 | This is meant to be a place of hacking and learning. We're still learning how to structure apps using Next.js's new App Router, and comments are welcome in Discussions. 12 | 13 | If you encounter an error (you will), please create an Issue so that we can fix bugs and learn together. 14 | 15 | **This is not intended for production.** For a production-ready full-stack application, use much more most stable [create-t3-app](https://github.com/t3-oss/create-t3-app). 16 | 17 | This project is not affiliated with create-t3-app. 18 | 19 | ## Features 20 | 21 | This project represents the copy-pasting of work and ideas from a lot of really smart people. I think it's useful to see them all together in a working prototype. 22 | 23 | - Edge runtime for all pages and routes. 24 | - Type-safe SQL with Kysely (plus Prisma schema management) 25 | - While create-t3-app uses Prisma, Prisma can't run on the Edge runtime. 26 | - Type-safe API with tRPC 27 | - App Router setup is copied from [here](https://github.com/trpc/next-13). 28 | - The installed tRPC version is currently locked to the experimental App Router tRPC client in `./src/trpc/@trpc`, which formats the react-query query keys in a specific way that changed in later versions of tRPC. If you upgrade tRPC, hydration will stop working. 29 | - Owned Authentication with Auth.js 30 | - Kysely adapter is copied from [here](https://github.com/nextauthjs/next-auth/pull/5464). 31 | - create-t3-app uses NextAuth, which doesn't support the Edge runtime. This project uses NextAuth's successor, Auth.js, which does. Since Auth.js hasn't built support for Next.js yet, their [SolidStart implementation](https://github.com/nextauthjs/next-auth/tree/36ad964cf9aec4561dd4850c0f42b7889aa9a7db/packages/frameworks-solid-start/src) is copied and slightly modified. 32 | - Styling with [Tailwind](https://tailwindcss.com/) 33 | - It's just CSS, so it works just fine in the App Router. 34 | - React components and layout from [shadcn/ui](https://github.com/shadcn/ui) 35 | - They're also just CSS and Radix, so they work just fine in the App Router. 36 | 37 | ## Data Fetching 38 | 39 | There are a few options that Server Components + tRPC + React Query afford us. The flexibility of these tools allows us to use different strategies for different cases on the same project. 40 | 41 | 1. Fetch data on the server and render on the server or pass it to client components. [Example.](https://github.com/mattddean/t3-app-router-edge/blob/03cd3c0d16fb08a208279e08d90014e8e4fc8322/src/app/profile/page.tsx#L14) 42 | 1. Fetch data on the server and use it to hydrate react-query's cache on the client. Example: [Fetch and dehydrate data on server](https://github.com/mattddean/t3-app-router-edge/blob/c64d8dd8246491b7c4314c764b13d493b616df09/src/app/page.tsx#L19-L39), then [use cached data from server on client](https://github.com/mattddean/t3-app-router-edge/blob/03cd3c0d16fb08a208279e08d90014e8e4fc8322/src/components/posts-table.tsx#L84-L87). 43 | 1. Fetch data on the client. 44 | 1. Fetch data the server but don't block first byte and stream Server Components to the client using a Suspense boundary. TODO: Example. 45 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | reactStrictMode: true, 6 | experimental: { 7 | appDir: true, 8 | typedRoutes: true, 9 | }, 10 | typescript: { 11 | // TODO: turn this off once we get things more stable 12 | ignoreBuildErrors: true, 13 | }, 14 | }; 15 | 16 | module.exports = nextConfig; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "t3-app-router-edge", 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:push": "pnpm with-env prisma db push", 11 | "db:studio": "pnpm with-env prisma studio", 12 | "dev-migrate": "prisma migrate dev", 13 | "postinstall": "prisma generate", 14 | "with-env": "dotenv -e ../../../.env --" 15 | }, 16 | "dependencies": { 17 | "@auth/core": "0.5.1", 18 | "@paralleldrive/cuid2": "^2.2.0", 19 | "@planetscale/database": "^1.6.0", 20 | "@prisma/client": "^4.8.0", 21 | "@radix-ui/react-avatar": "^1.0.2", 22 | "@radix-ui/react-dialog": "^1.0.3", 23 | "@radix-ui/react-dropdown-menu": "^2.0.4", 24 | "@radix-ui/react-hover-card": "^1.0.5", 25 | "@radix-ui/react-label": "^2.0.1", 26 | "@radix-ui/react-navigation-menu": "^1.1.2", 27 | "@radix-ui/react-popover": "^1.0.5", 28 | "@radix-ui/react-scroll-area": "^1.0.3", 29 | "@radix-ui/react-select": "^1.2.1", 30 | "@radix-ui/react-separator": "^1.0.2", 31 | "@radix-ui/react-tooltip": "^1.0.5", 32 | "@tailwindcss/line-clamp": "^0.4.2", 33 | "@tailwindcss/typography": "^0.5.9", 34 | "@tanstack/query-core": "4.14.5", 35 | "@tanstack/react-query": "4.14.5", 36 | "@tanstack/react-query-devtools": "4.14.5", 37 | "@tanstack/react-table": "^8.7.9", 38 | "@trpc/client": "10.0.0-rc.1", 39 | "@trpc/react-query": "10.0.0-rc.1", 40 | "@trpc/server": "10.0.0-rc.1", 41 | "buffer": "^6.0.3", 42 | "class-variance-authority": "^0.4.0", 43 | "clsx": "^1.2.1", 44 | "cookie": "^0.5.0", 45 | "date-fns": "^2.29.3", 46 | "kysely": "0.21.6", 47 | "kysely-planetscale": "^1.3.0", 48 | "lucide-react": "^0.109.0", 49 | "next": "^13.2.4", 50 | "next-themes": "^0.2.1", 51 | "prisma": "^4.8.0", 52 | "react": "18.2.0", 53 | "react-dom": "^18.2.0", 54 | "set-cookie-parser": "^2.5.1", 55 | "superjson": "^1.12.2", 56 | "tailwind-merge": "^1.9.1", 57 | "tailwindcss-animate": "^1.0.5", 58 | "zod": "^3.21.4" 59 | }, 60 | "devDependencies": { 61 | "@types/cookie": "^0.5.1", 62 | "@types/node": "18.8.5", 63 | "@types/react": "18.0.21", 64 | "@types/set-cookie-parser": "^2.4.2", 65 | "@typescript-eslint/eslint-plugin": "^5.54.1", 66 | "@typescript-eslint/parser": "^5.54.1", 67 | "autoprefixer": "^10.4.14", 68 | "dotenv-cli": "^6.0.0", 69 | "eslint": "8.25.0", 70 | "eslint-config-next": "12.3.1", 71 | "eslint-config-prettier": "^8.7.0", 72 | "eslint-plugin-only-warn": "^1.1.0", 73 | "postcss": "^8.4.21", 74 | "prettier": "^2.8.1", 75 | "prettier-plugin-tailwindcss": "^0.2.4", 76 | "tailwindcss": "^3.2.7", 77 | "typescript": "4.8.4" 78 | }, 79 | "pnpm": { 80 | "patchedDependencies": { 81 | "@tanstack/react-query@4.14.5": "patches/@tanstack__react-query@4.14.5.patch" 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /patches/@tanstack__react-query@4.14.5.patch: -------------------------------------------------------------------------------- 1 | diff --git a/build/lib/reactBatchedUpdates.mjs b/build/lib/reactBatchedUpdates.mjs 2 | index 8a5ec0f3acd8582e6d63573a9479b9cae6b40f88..48c77d58736392bc3712651c978f4f5e48697993 100644 3 | --- a/build/lib/reactBatchedUpdates.mjs 4 | +++ b/build/lib/reactBatchedUpdates.mjs 5 | @@ -1,6 +1,6 @@ 6 | -import * as ReactDOM from 'react-dom'; 7 | - 8 | -const unstable_batchedUpdates = ReactDOM.unstable_batchedUpdates; 9 | +const unstable_batchedUpdates = (callback) => { 10 | + callback() 11 | +} 12 | 13 | export { unstable_batchedUpdates }; 14 | //# sourceMappingURL=reactBatchedUpdates.mjs.map -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | module.exports = { 3 | printWidth: 120, 4 | trailingComma: "all", 5 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 6 | tailwindConfig: "./tailwind.config.cjs", 7 | }; 8 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "mysql" 10 | url = env("DATABASE_URL") 11 | relationMode = "prisma" 12 | } 13 | 14 | // NextAuth.js Models 15 | // NOTE: When using postgresql, mysql or sqlserver, 16 | // uncomment the @db.Text annotations below 17 | // @see https://next-auth.js.org/schemas/models 18 | model Account { 19 | id String @id @default(cuid()) 20 | userId String 21 | type String 22 | provider String 23 | providerAccountId String 24 | 25 | // OpenIDTokenEndpointResponse properties 26 | access_token String? @db.Text 27 | expires_in Int? 28 | id_token String? @db.Text 29 | refresh_token String? @db.Text 30 | refresh_token_expires_in Int? 31 | scope String? 32 | token_type String? 33 | 34 | createdAt DateTime @default(now()) 35 | updatedAt DateTime @updatedAt 36 | 37 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 38 | 39 | @@unique([provider, providerAccountId]) 40 | @@index([userId]) 41 | } 42 | 43 | model Session { 44 | id String @id @default(cuid()) 45 | sessionToken String @unique 46 | userId String 47 | expires DateTime 48 | 49 | created_at DateTime @default(now()) 50 | updated_at DateTime @updatedAt 51 | 52 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 53 | 54 | @@index([userId]) 55 | } 56 | 57 | model User { 58 | id String @id @default(cuid()) 59 | name String? 60 | email String @unique 61 | emailVerified DateTime? 62 | image String? 63 | 64 | created_at DateTime @default(now()) 65 | updated_at DateTime @updatedAt 66 | 67 | accounts Account[] 68 | sessions Session[] 69 | posts Post[] 70 | } 71 | 72 | model VerificationToken { 73 | identifier String 74 | token String @unique 75 | expires DateTime 76 | 77 | created_at DateTime @default(now()) 78 | updated_at DateTime @updatedAt 79 | 80 | @@unique([identifier, token]) 81 | } 82 | 83 | model Post { 84 | id String @id @default(cuid()) 85 | user_id String 86 | // URL slug 87 | slug String @unique @default(cuid()) 88 | title String @db.Text 89 | text String @db.Text 90 | 91 | created_at DateTime @default(now()) 92 | updated_at DateTime @updatedAt 93 | User User? @relation(fields: [user_id], references: [id]) 94 | 95 | @@index([user_id]) 96 | } 97 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattddean/t3-app-router-edge/062847dda3df810065eff28318e6e11d293ba159/public/favicon.ico -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | import { authConfig } from "~/auth/options"; 3 | import { SolidAuthHandler } from "~/auth/server"; 4 | 5 | export const runtime = "edge"; 6 | 7 | async function handler(request: NextRequest) { 8 | const { prefix = "/api/auth", ...authOptions } = authConfig; 9 | 10 | authOptions.secret ??= process.env.AUTH_SECRET; 11 | authOptions.trustHost ??= !!( 12 | process.env.AUTH_TRUST_HOST ?? 13 | process.env.VERCEL ?? 14 | process.env.NODE_ENV !== "production" 15 | ); 16 | 17 | // Create a new request so that we can ensure the next headers are accessed in this file. 18 | // If we pass the request we get from next to SolidAuthHandler, it will access the headers 19 | // in a way that next.js does not like and we'll end up with a requestAsyncStorage error 20 | // https://github.com/vercel/next.js/issues/46356 21 | const req = new Request(request.url, { 22 | headers: request.headers, 23 | cache: request.cache, 24 | credentials: request.credentials, 25 | integrity: request.integrity, 26 | keepalive: request.keepalive, 27 | method: request.method, 28 | mode: request.mode, 29 | redirect: request.redirect, 30 | referrer: request.referrer, 31 | referrerPolicy: request.referrerPolicy, 32 | signal: request.signal, 33 | body: request.body, 34 | }); 35 | 36 | const response = await SolidAuthHandler(req, prefix, authOptions); 37 | return response; 38 | } 39 | 40 | export { handler as GET, handler as POST }; 41 | -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import { cookies } from "next/headers"; 3 | import type { NextRequest } from "next/server"; 4 | import { createContext } from "~/server/context"; 5 | import { appRouter } from "~/server/routers/_app"; 6 | import { createGetUser } from "~/shared/server-rsc/get-user"; 7 | 8 | export const runtime = "edge"; 9 | 10 | const handler = (request: NextRequest) => { 11 | const req = new Request(request.url, { 12 | headers: request.headers, 13 | cache: request.cache, 14 | credentials: request.credentials, 15 | integrity: request.integrity, 16 | keepalive: request.keepalive, 17 | method: request.method, 18 | mode: request.mode, 19 | redirect: request.redirect, 20 | referrer: request.referrer, 21 | referrerPolicy: request.referrerPolicy, 22 | signal: request.signal, 23 | body: request.body, 24 | }); 25 | 26 | // We have to call cookies() in this file and then pass them in or Next.js's App Router will complain. 27 | const asyncStorageCookies = cookies(); 28 | 29 | return fetchRequestHandler({ 30 | endpoint: "/api/trpc", 31 | req, 32 | router: appRouter, 33 | createContext(opts) { 34 | return createContext({ 35 | type: "api", 36 | getUser: createGetUser(asyncStorageCookies), 37 | ...opts, 38 | }); 39 | }, 40 | onError({ error }) { 41 | if (error.code === "INTERNAL_SERVER_ERROR") { 42 | console.error("Caught TRPC error:", error); 43 | } 44 | }, 45 | }); 46 | }; 47 | 48 | export { handler as GET, handler as POST }; 49 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body { 7 | padding: 0; 8 | margin: 0; 9 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 10 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 11 | } 12 | 13 | a { 14 | color: inherit; 15 | text-decoration: none; 16 | } 17 | 18 | * { 19 | box-sizing: border-box; 20 | } 21 | 22 | @media (prefers-color-scheme: dark) { 23 | html { 24 | color-scheme: dark; 25 | } 26 | body { 27 | color: white; 28 | background: black; 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | 3 | import { Inter as FontSans } from "next/font/google"; 4 | import Link from "next/link"; 5 | import { PropsWithChildren } from "react"; 6 | import { LogoIcon } from "~/components/icons"; 7 | import { MainNav } from "~/components/main-nav/main-nav"; 8 | import { MobileNav } from "~/components/mobile-nav"; 9 | import { ThemeProvider } from "~/components/theme-provider"; 10 | import { cn } from "~/components/ui/lib/utils"; 11 | import { rsc } from "~/shared/server-rsc/trpc"; 12 | import { ClientProvider } from "~/trpc/client/trpc-client"; 13 | import { MainDropdownMenu } from "../components/main-dropdown-menu"; 14 | 15 | const fontSans = FontSans({ 16 | subsets: ["latin"], 17 | variable: "--font-sans", 18 | display: "swap", 19 | }); 20 | 21 | export const metadata = { 22 | title: { 23 | default: "T3 App Router (Edge)", 24 | template: "%s | T3 App Router (Edge)", 25 | }, 26 | description: "Example app.", 27 | }; 28 | 29 | export default async function RootLayout(props: PropsWithChildren) { 30 | const user = await rsc.whoami.fetch(); 31 | 32 | const avatarFallbackText = (() => { 33 | const userName = user?.name; 34 | const firstLetterOfUsername = userName?.[0]; 35 | return firstLetterOfUsername?.toUpperCase(); 36 | })(); 37 | 38 | return ( 39 | 40 | 41 | 47 | 48 |
49 |
50 |
51 | 52 | 53 | {/* Avatar */} 54 |
55 | 58 |
59 |
60 |
61 | 62 |
63 |
64 |

65 | T3 App Router (Edge) 66 |

67 | {props.children} 68 |
69 | 70 |
71 |
72 | 73 |
74 |
75 |
76 | 77 |

78 | 79 | Profile 80 | 81 |

82 |
83 |
84 |
85 |
86 |
87 | 88 |
89 | 90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | import { PostsTable } from "~/components/posts-table"; 3 | import SignInButtons from "~/components/sign-in-options"; 4 | import { rsc } from "~/shared/server-rsc/trpc"; 5 | import { HydrateClient } from "~/trpc/client/hydrate-client"; 6 | 7 | export const runtime = "edge"; 8 | 9 | export const metadata = { 10 | title: "Home", 11 | description: "Home", 12 | }; 13 | 14 | /* @ts-expect-error Async Server Component */ 15 | const Home: FC = async () => { 16 | const pageSizes: [number, number, number] = [10, 25, 50]; 17 | const initialPageSize = pageSizes[0]; 18 | 19 | // Fetch the first page of data that PostsTable will look for so that it 20 | // can be dehydrated, passed to the client, and instantly retrieved. 21 | const [user] = await Promise.all([ 22 | rsc.whoami.fetch(), 23 | rsc.example.getInfinitePosts.fetchInfinite({ limit: initialPageSize }), 24 | ]); 25 | 26 | const dehydratedState = await rsc.dehydrate(); 27 | return ( 28 | <> 29 |
30 |
31 | {!user && } 32 | 33 | {/* Provide dehydrated state to client components. */} 34 | 35 | 36 | 37 |
38 | 39 | ); 40 | }; 41 | 42 | export default Home; 43 | -------------------------------------------------------------------------------- /src/app/post/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import SignInButtons from "~/components/sign-in-options"; 3 | import { rsc } from "~/shared/server-rsc/trpc"; 4 | 5 | export const runtime = "edge"; 6 | 7 | export interface Props { 8 | params: { slug: string }; 9 | } 10 | 11 | /* @ts-expect-error Async Server Component */ 12 | const PostSlug: NextPage = async ({ params }) => { 13 | const user = await rsc.whoami.fetch(); 14 | const post = await rsc.example.getPost.fetch({ slug: params.slug }); 15 | 16 | return ( 17 | <> 18 |
19 | 20 | {!user && } 21 | 22 |
23 | {post && ( 24 | <> 25 |
26 |
27 |
28 |
{post.title}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
{post.text}
36 |
37 |
38 |
39 | 40 | )} 41 |
42 | 43 | ); 44 | }; 45 | 46 | export default PostSlug; 47 | -------------------------------------------------------------------------------- /src/app/posts/create/create-post-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FC, FormEventHandler, useState } from "react"; 4 | import { Button } from "../../../components/ui/button"; 5 | import { Input } from "../../../components/ui/input"; 6 | import { Label } from "../../../components/ui/label"; 7 | import { api } from "../../../trpc/client/trpc-client"; 8 | 9 | const CreatePostForm: FC = () => { 10 | const [postTitle, setPostTitle] = useState(""); 11 | const [postText, setPostText] = useState(""); 12 | const createPostMutation = api.example.createPost.useMutation(); 13 | 14 | const canSubmitChangePassword = postTitle && postText; 15 | const createPost: FormEventHandler = (e) => { 16 | e.preventDefault(); 17 | if (!canSubmitChangePassword) throw new Error("Invalid passwords input"); // the ui should have prevented this 18 | void createPostMutation.mutateAsync({ title: postTitle, text: postText }); 19 | }; 20 | 21 | return ( 22 |
23 |
24 |
25 | 26 |
27 | setPostTitle(e.target.value)} 31 | className="flex-1" 32 | autoComplete="new-password" 33 | /> 34 |
35 |
36 |
37 | 38 |
39 |
40 | 41 |
42 | setPostText(e.target.value)} 46 | className="flex-1" 47 | autoComplete="new-password" 48 | /> 49 |
50 |
51 |
52 | 53 |
54 | 55 |
56 |
57 | ); 58 | }; 59 | 60 | export default CreatePostForm; 61 | -------------------------------------------------------------------------------- /src/app/posts/create/page.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import SignInButtons from "~/components/sign-in-options"; 3 | import { rsc } from "~/shared/server-rsc/trpc"; 4 | import CreatePostForm from "./create-post-form"; 5 | 6 | export const runtime = "edge"; 7 | 8 | export const metadata = { 9 | title: "Home", 10 | description: "Home", 11 | }; 12 | 13 | /* @ts-expect-error Async Server Component */ 14 | const CreatePost: FC = async () => { 15 | const user = await rsc.whoami.fetch(); 16 | 17 | return ( 18 | <> 19 |
20 |
21 | {!user && } 22 | 23 | {!!user ? :
You must sign in to create a post.
} 24 |
25 | 26 | ); 27 | }; 28 | 29 | export default CreatePost; 30 | -------------------------------------------------------------------------------- /src/app/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import SignInButtons from "~/components/sign-in-options"; 3 | import { rsc } from "../../shared/server-rsc/trpc"; 4 | 5 | export const runtime = "edge"; 6 | 7 | export const metadata = { 8 | title: "Profile", 9 | description: "Your profile.", 10 | }; 11 | 12 | /* @ts-expect-error Async Server Component */ 13 | const Home: NextPage = async () => { 14 | const user = await rsc.whoami.fetch(); 15 | 16 | return ( 17 | <> 18 |
19 | 20 |
21 | {!user && } 22 | 23 | {!!user ? ( 24 |
25 |
26 |

27 | Account Information 28 |

29 |
30 |
31 |
32 |
Username
33 |
{user.name}
34 |
35 |
36 |
Email
37 |
{user.email}
38 |
39 |
40 |
41 |
42 | ) : ( 43 |
You must sign in to view your profile.
44 | )} 45 |
46 | 47 | ); 48 | }; 49 | 50 | export default Home; 51 | -------------------------------------------------------------------------------- /src/auth/adapters/kysely.ts: -------------------------------------------------------------------------------- 1 | import type { Adapter } from "@auth/core/adapters"; 2 | import { createId } from "@paralleldrive/cuid2"; 3 | import { Kysely, SqliteAdapter } from "kysely"; 4 | import type { Database } from "~/lib/kysely-db"; 5 | 6 | // https://github.com/nextauthjs/next-auth/pull/5464 7 | 8 | type ReturnData = Record; 9 | 10 | export function KyselyAdapter(db: Kysely): Adapter { 11 | const adapter = db.getExecutor().adapter; 12 | const supportsReturning = adapter.supportsReturning; 13 | const storeDatesAsISOStrings = adapter instanceof SqliteAdapter; 14 | 15 | /** Helper function to return the passed in object and its specified prop 16 | * as an ISO string if SQLite is being used. 17 | **/ 18 | function coerceInputData>, K extends keyof T>(data: T, key: K) { 19 | const value = data[key]; 20 | return { 21 | ...data, 22 | [key]: value && storeDatesAsISOStrings ? value.toISOString() : value, 23 | }; 24 | } 25 | 26 | /** 27 | * Helper function to return the passed in object and its specified prop as a date. 28 | * Necessary because SQLite has no date type so we store dates as ISO strings. 29 | **/ 30 | function coerceReturnData, K extends keyof T>( 31 | data: T, 32 | key: K, 33 | ): Omit & Record; 34 | function coerceReturnData>, K extends keyof T>( 35 | data: T, 36 | key: K, 37 | ): Omit & Record; 38 | function coerceReturnData>, K extends keyof T>(data: T, key: K) { 39 | const value = data[key]; 40 | return Object.assign(data, { 41 | [key]: value && typeof value === "string" ? new Date(value) : value, 42 | }); 43 | } 44 | 45 | return { 46 | async createUser(data) { 47 | const userData = coerceInputData(data, "emailVerified"); 48 | const now = new Date(); 49 | const query = db.insertInto("User").values([ 50 | { 51 | id: createId(), 52 | created_at: now, 53 | updated_at: now, 54 | email: userData.email, 55 | emailVerified: userData.emailVerified, 56 | name: userData.name, 57 | image: userData.image, 58 | }, 59 | ]); 60 | const result = supportsReturning 61 | ? await query.returningAll().executeTakeFirstOrThrow() 62 | : await query.executeTakeFirstOrThrow().then(async () => { 63 | return await db 64 | .selectFrom("User") 65 | .selectAll() 66 | .where("email", "=", `${userData.email}`) 67 | .executeTakeFirstOrThrow(); 68 | }); 69 | return coerceReturnData(result, "emailVerified"); 70 | }, 71 | async getUser(id) { 72 | const result = (await db.selectFrom("User").selectAll().where("id", "=", id).executeTakeFirst()) ?? null; 73 | if (!result) return null; 74 | return coerceReturnData(result, "emailVerified"); 75 | }, 76 | async getUserByEmail(email) { 77 | const result = (await db.selectFrom("User").selectAll().where("email", "=", email).executeTakeFirst()) ?? null; 78 | if (!result) return null; 79 | return coerceReturnData(result, "emailVerified"); 80 | }, 81 | async getUserByAccount({ providerAccountId, provider }) { 82 | const result = 83 | (await db 84 | .selectFrom("User") 85 | .innerJoin("Account", "User.id", "Account.userId") 86 | .selectAll("User") 87 | .where("Account.providerAccountId", "=", providerAccountId) 88 | .where("Account.provider", "=", provider) 89 | .executeTakeFirst()) ?? null; 90 | if (!result) return null; 91 | return coerceReturnData(result, "emailVerified"); 92 | }, 93 | async updateUser({ id, ...user }) { 94 | if (!id) throw new Error("User not found"); 95 | const userData = coerceInputData(user, "emailVerified"); 96 | const query = db.updateTable("User").set(userData).where("id", "=", id); 97 | const result = supportsReturning 98 | ? await query.returningAll().executeTakeFirstOrThrow() 99 | : await query.executeTakeFirstOrThrow().then(async () => { 100 | return await db.selectFrom("User").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); 101 | }); 102 | return coerceReturnData(result, "emailVerified"); 103 | }, 104 | async deleteUser(userId) { 105 | await db.deleteFrom("User").where("User.id", "=", userId).execute(); 106 | }, 107 | async linkAccount(account) { 108 | const now = new Date(); 109 | await db 110 | .insertInto("Account") 111 | .values([ 112 | { 113 | id: createId(), 114 | createdAt: now, 115 | updatedAt: now, 116 | provider: account.provider, 117 | providerAccountId: account.providerAccountId, 118 | type: account.type, 119 | userId: account.userId, 120 | // OpenIDTokenEndpointResponse properties 121 | access_token: account.access_token, 122 | expires_in: account.expires_in, 123 | id_token: account.id_token, 124 | refresh_token: account.refresh_token, 125 | refresh_token_expires_in: account.refresh_token_expires_in as number, // TODO: why doesn't the account type have this property? 126 | scope: account.scope, 127 | token_type: account.token_type, 128 | }, 129 | ]) 130 | .executeTakeFirstOrThrow(); 131 | }, 132 | async unlinkAccount({ providerAccountId, provider }) { 133 | await db 134 | .deleteFrom("Account") 135 | .where("Account.providerAccountId", "=", providerAccountId) 136 | .where("Account.provider", "=", provider) 137 | .executeTakeFirstOrThrow(); 138 | }, 139 | async createSession(data) { 140 | const sessionData = coerceInputData(data, "expires"); 141 | const now = new Date(); 142 | const query = db.insertInto("Session").values([ 143 | { 144 | id: createId(), 145 | created_at: now, 146 | updated_at: now, 147 | expires: data.expires, 148 | sessionToken: data.sessionToken, 149 | userId: data.userId, 150 | }, 151 | ]); 152 | const result = supportsReturning 153 | ? await query.returningAll().executeTakeFirstOrThrow() 154 | : await (async () => { 155 | await query.executeTakeFirstOrThrow(); 156 | return await db 157 | .selectFrom("Session") 158 | .selectAll() 159 | .where("sessionToken", "=", sessionData.sessionToken) 160 | .executeTakeFirstOrThrow(); 161 | })(); 162 | return coerceReturnData(result, "expires"); 163 | }, 164 | async getSessionAndUser(sessionTokenArg) { 165 | const result = await db 166 | .selectFrom("Session") 167 | .innerJoin("User", "User.id", "Session.userId") 168 | .selectAll("User") 169 | .select(["Session.id as sessionId", "Session.userId", "Session.sessionToken", "Session.expires"]) 170 | .where("Session.sessionToken", "=", sessionTokenArg) 171 | .executeTakeFirst(); 172 | if (!result) return null; 173 | const { sessionId: id, userId, sessionToken, expires, ...user } = result; 174 | return { 175 | user: coerceReturnData({ ...user }, "emailVerified"), 176 | session: coerceReturnData({ id, userId, sessionToken, expires }, "expires"), 177 | }; 178 | }, 179 | async updateSession(session) { 180 | const sessionData = coerceInputData(session, "expires"); 181 | const query = db.updateTable("Session").set(sessionData).where("Session.sessionToken", "=", session.sessionToken); 182 | const result = supportsReturning 183 | ? await query.returningAll().executeTakeFirstOrThrow() 184 | : await query.executeTakeFirstOrThrow().then(async () => { 185 | return await db 186 | .selectFrom("Session") 187 | .selectAll() 188 | .where("Session.sessionToken", "=", sessionData.sessionToken) 189 | .executeTakeFirstOrThrow(); 190 | }); 191 | return coerceReturnData(result, "expires"); 192 | }, 193 | async deleteSession(sessionToken) { 194 | await db.deleteFrom("Session").where("Session.sessionToken", "=", sessionToken).executeTakeFirstOrThrow(); 195 | }, 196 | async createVerificationToken(verificationToken) { 197 | const verificationTokenData = coerceInputData(verificationToken, "expires"); 198 | const now = new Date(); 199 | const query = db.insertInto("VerificationToken").values([ 200 | { 201 | created_at: now, 202 | updated_at: now, 203 | expires: verificationToken.expires, 204 | identifier: verificationToken.identifier, 205 | token: verificationToken.token, 206 | }, 207 | ]); 208 | const result = supportsReturning 209 | ? await query.returningAll().executeTakeFirstOrThrow() 210 | : await query.executeTakeFirstOrThrow().then(async () => { 211 | return await db 212 | .selectFrom("VerificationToken") 213 | .selectAll() 214 | .where("token", "=", verificationTokenData.token) 215 | .executeTakeFirstOrThrow(); 216 | }); 217 | return coerceReturnData(result, "expires"); 218 | }, 219 | async useVerificationToken({ identifier, token }) { 220 | const query = db 221 | .deleteFrom("VerificationToken") 222 | .where("VerificationToken.token", "=", token) 223 | .where("VerificationToken.identifier", "=", identifier); 224 | const result = supportsReturning 225 | ? (await query.returningAll().executeTakeFirst()) ?? null 226 | : await db 227 | .selectFrom("VerificationToken") 228 | .selectAll() 229 | .where("token", "=", token) 230 | .executeTakeFirst() 231 | .then(async (res) => { 232 | await query.executeTakeFirst(); 233 | return res; 234 | }); 235 | if (!result) return null; 236 | return coerceReturnData(result, "expires"); 237 | }, 238 | }; 239 | } 240 | 241 | /** 242 | * The following is a reference of the database schema that @auth/core requires 243 | */ 244 | 245 | import type { Generated } from "kysely"; 246 | 247 | interface User { 248 | id: Generated; 249 | name: string | null; 250 | email: string | null; 251 | emailVerified: Date | string | null; 252 | image: string | null; 253 | } 254 | 255 | interface Account { 256 | id: Generated; 257 | userId: string; 258 | type: string; 259 | provider: string; 260 | providerAccountId: string; 261 | access_token: string | null; 262 | expires_in: number | null; 263 | id_token: string | null; 264 | refresh_token: string | null; 265 | refresh_token_expires_in: number | null; 266 | scope: string | null; 267 | token_type: string | null; 268 | /** I don't think this property is actually required by @auth/core */ 269 | // expires_at: number | null; 270 | /** I don't think this property is actually required by @auth/core */ 271 | // session_state: string | null; 272 | /** I don't think this property is actually required by @auth/core */ 273 | // oauth_token_secret: string | null; 274 | /** I don't think this property is actually required by @auth/core */ 275 | // oauth_token: string | null; 276 | } 277 | 278 | interface Session { 279 | id: Generated; 280 | userId: string; 281 | sessionToken: string; 282 | expires: Date | string; 283 | } 284 | 285 | interface VerificationToken { 286 | identifier: string; 287 | token: string; 288 | expires: Date | string; 289 | } 290 | 291 | interface ReferenceSchema { 292 | User: User; 293 | Account: Account; 294 | Session: Session; 295 | VerificationToken: VerificationToken; 296 | } 297 | 298 | /** 299 | * Wrapper over the original Kysely class in order to validate the passed in 300 | * database interface. A regular Kysely instance may also be used, but wrapping 301 | * it ensures the database interface implements the fields that NextAuth 302 | * requires. When used with kysely-codegen, the Codegen type can be passed as 303 | * the second generic argument. The generated types will be used, and 304 | * AuthedKysely will only verify that the correct fields exist. 305 | **/ 306 | export class AuthedKysely extends Kysely {} 307 | 308 | export type Codegen = { 309 | [K in keyof ReferenceSchema]: { [J in keyof ReferenceSchema[K]]: unknown }; 310 | }; 311 | -------------------------------------------------------------------------------- /src/auth/client/index.ts: -------------------------------------------------------------------------------- 1 | import type { BuiltInProviderType, RedirectableProviderType } from "@auth/core/providers"; 2 | import type { LiteralUnion, SignInAuthorizationParams, SignInOptions, SignOutParams } from "next-auth/react"; 3 | 4 | /** 5 | * Client-side method to initiate a signin flow 6 | * or send the user to the signin page listing all possible providers. 7 | * Automatically adds the CSRF token to the request. 8 | * 9 | * [Documentation](https://next-auth.js.org/getting-started/client#signin) 10 | */ 11 | export async function signIn

( 12 | providerId?: LiteralUnion

, 13 | options?: SignInOptions, 14 | authorizationParams?: SignInAuthorizationParams, 15 | ) { 16 | const { callbackUrl = window.location.href, redirect = true } = options ?? {}; 17 | 18 | // TODO: Support custom providers 19 | const isCredentials = providerId === "credentials"; 20 | const isEmail = providerId === "email"; 21 | const isSupportingReturn = isCredentials || isEmail; 22 | 23 | // TODO: Handle custom base path 24 | const signInUrl = `/api/auth/${isCredentials ? "callback" : "signin"}/${providerId}`; 25 | 26 | const _signInUrl = `${signInUrl}?${new URLSearchParams(authorizationParams)}`; 27 | 28 | // TODO: Handle custom base path 29 | const csrfTokenResponse = await fetch("/api/auth/csrf"); 30 | const { csrfToken } = await csrfTokenResponse.json(); 31 | 32 | const res = await fetch(_signInUrl, { 33 | method: "post", 34 | headers: { 35 | "Content-Type": "application/x-www-form-urlencoded", 36 | "X-Auth-Return-Redirect": "1", 37 | }, 38 | // @ts-expect-error -- ignore 39 | body: new URLSearchParams({ 40 | ...options, 41 | csrfToken, 42 | callbackUrl, 43 | }), 44 | }); 45 | 46 | const data = await res.clone().json(); 47 | const error = new URL(data.url).searchParams.get("error"); 48 | if (redirect || !isSupportingReturn || !error) { 49 | // TODO: Do not redirect for Credentials and Email providers by default in next major 50 | window.location.href = data.url ?? data.redirect ?? callbackUrl; 51 | // If url contains a hash, the browser does not reload the page. We reload manually 52 | if (data.url.includes("#")) window.location.reload(); 53 | return; 54 | } 55 | return res; 56 | } 57 | 58 | /** 59 | * Signs the user out, by removing the session cookie. 60 | * Automatically adds the CSRF token to the request. 61 | * 62 | * [Documentation](https://next-auth.js.org/getting-started/client#signout) 63 | */ 64 | export async function signOut(options?: SignOutParams) { 65 | const { callbackUrl = window.location.href } = options ?? {}; 66 | // TODO: Custom base path 67 | const csrfTokenResponse = await fetch("/api/auth/csrf"); 68 | const { csrfToken } = await csrfTokenResponse.json(); 69 | const res = await fetch(`/api/auth/signout`, { 70 | method: "post", 71 | headers: { 72 | "Content-Type": "application/x-www-form-urlencoded", 73 | "X-Auth-Return-Redirect": "1", 74 | }, 75 | body: new URLSearchParams({ 76 | csrfToken, 77 | callbackUrl, 78 | }), 79 | }); 80 | const data = await res.json(); 81 | 82 | const url = data.url ?? data.redirect ?? callbackUrl; 83 | window.location.href = url; 84 | // If url contains a hash, the browser does not reload the page. We reload manually 85 | if (url.includes("#")) window.location.reload(); 86 | } 87 | -------------------------------------------------------------------------------- /src/auth/options.ts: -------------------------------------------------------------------------------- 1 | import GithubProvider from "@auth/core/providers/github"; 2 | import GoogleProvider from "@auth/core/providers/google"; 3 | import { KyselyAdapter } from "~/auth/adapters/kysely"; 4 | import { db } from "~/lib/kysely-db"; 5 | import { SolidAuthConfig } from "./server"; 6 | 7 | export const authConfig: SolidAuthConfig = { 8 | // Configure one or more authentication providers 9 | adapter: KyselyAdapter(db), 10 | providers: [ 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 12 | // @ts-ignore growing pains 13 | GithubProvider({ 14 | clientId: process.env.GITHUB_ID as string, 15 | clientSecret: process.env.GITHUB_SECRET as string, 16 | }), 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 18 | // @ts-ignore growing pains 19 | GoogleProvider({ 20 | clientId: process.env.GOOGLE_CLIENT_ID as string, 21 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, 22 | }), 23 | ], 24 | callbacks: { 25 | session({ session, user }) { 26 | if (session.user) { 27 | session.user.id = user.id; 28 | } 29 | return session; 30 | }, 31 | }, 32 | session: { 33 | strategy: "database", 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/auth/server/index.ts: -------------------------------------------------------------------------------- 1 | /** https://github.com/nextauthjs/next-auth/blob/04791cd57478b64d0ebdfc8fe25779e2f89e2070/packages/frameworks-solid-start/src/index.ts#L1 */ 2 | 3 | import { Auth } from "@auth/core"; 4 | import type { AuthAction, AuthConfig, Session } from "@auth/core/types"; 5 | import { serialize } from "cookie"; 6 | import { parseString, splitCookiesString, type Cookie } from "set-cookie-parser"; 7 | 8 | export interface SolidAuthConfig extends AuthConfig { 9 | /** 10 | * Defines the base path for the auth routes. 11 | * @default '/api/auth' 12 | */ 13 | prefix?: string; 14 | } 15 | 16 | const actions: AuthAction[] = [ 17 | "providers", 18 | "session", 19 | "csrf", 20 | "signin", 21 | "signout", 22 | "callback", 23 | "verify-request", 24 | "error", 25 | ]; 26 | 27 | // currently multiple cookies are not supported, so we keep the next-auth.pkce.code_verifier cookie for now: 28 | // because it gets updated anyways 29 | // src: https://github.com/solidjs/solid-start/issues/293 30 | const getSetCookieCallback = (cook?: string | null): Cookie | undefined => { 31 | if (!cook) return; 32 | const splitCookie = splitCookiesString(cook); 33 | for (const cookName of [ 34 | "__Secure-next-auth.session-token", 35 | "next-auth.session-token", 36 | "next-auth.pkce.code_verifier", 37 | "__Secure-next-auth.pkce.code_verifier", 38 | ]) { 39 | const temp = splitCookie.find((e) => e.startsWith(`${cookName}=`)); 40 | if (temp) { 41 | return parseString(temp); 42 | } 43 | } 44 | return parseString(splitCookie?.[0] ?? ""); // just return the first cookie if no session token is found 45 | }; 46 | 47 | export async function SolidAuthHandler(request: Request, prefix: string, authOptions: SolidAuthConfig) { 48 | const url = new URL(request.url); 49 | const action = url.pathname.slice(prefix.length + 1).split("/")[0] as AuthAction; 50 | 51 | if (!actions.includes(action) || !url.pathname.startsWith(prefix + "/")) { 52 | return; 53 | } 54 | 55 | const res = await Auth(request, authOptions); 56 | if (["callback", "signin", "signout"].includes(action)) { 57 | const parsedCookie = getSetCookieCallback(res.clone().headers.get("Set-Cookie")); 58 | if (parsedCookie) { 59 | res.headers.set("Set-Cookie", serialize(parsedCookie.name, parsedCookie.value, parsedCookie as any)); 60 | } 61 | } 62 | return res; 63 | } 64 | 65 | export async function getSession(req: Request, options: AuthConfig): Promise { 66 | options.secret ??= process.env.AUTH_SECRET; 67 | options.trustHost ??= true; 68 | 69 | const url = new URL("/api/auth/session", req.url); 70 | const response = await Auth(new Request(url, { headers: req.headers }), options); 71 | 72 | const { status = 200 } = response; 73 | 74 | const data = await response.json(); 75 | 76 | if (!data || !Object.keys(data).length) return null; 77 | if (status === 200) return data; 78 | throw new Error(data.message); 79 | } 80 | -------------------------------------------------------------------------------- /src/components/icons.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | ChevronLeft, 5 | ChevronRight, 6 | Loader2, 7 | LogOut, 8 | LucideProps, 9 | User, 10 | X, 11 | type Icon as LucideIcon, 12 | } from "lucide-react"; 13 | import { FC } from "react"; 14 | 15 | export type Icon = LucideIcon; 16 | 17 | const Logo: FC = (props) => ( 18 |

19 | 28 | 29 | 30 |
31 | ); 32 | 33 | const GithubIcon: FC = (props: LucideProps) => ( 34 | 35 | 39 | 40 | ); 41 | 42 | const GoogleIcon: FC = (props: LucideProps) => ( 43 | 44 | 50 | 51 | ); 52 | 53 | export { 54 | Loader2 as SpinnerIcon, 55 | X as CloseIcon, 56 | GithubIcon, 57 | GoogleIcon, 58 | Logo as LogoIcon, 59 | ChevronLeft as ChevronLeftIcon, 60 | ChevronRight as ChevronRightIcon, 61 | LogOut as LogOutIcon, 62 | User as UserIcon, 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/main-dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import type { FC } from "react"; 5 | import { signOut } from "~/auth/client"; 6 | import { LogOutIcon, UserIcon } from "~/components/icons"; 7 | import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; 8 | import { Button } from "~/components/ui/button"; 9 | import { 10 | DropdownMenu, 11 | DropdownMenuContent, 12 | DropdownMenuGroup, 13 | DropdownMenuItem, 14 | DropdownMenuLabel, 15 | DropdownMenuSeparator, 16 | DropdownMenuTrigger, 17 | } from "~/components/ui/dropdown-menu"; 18 | 19 | export interface Props { 20 | avatarFallbackText?: string; 21 | user: { email?: string | undefined; image: string | undefined }; 22 | } 23 | 24 | export const MainDropdownMenu: FC = ({ user, avatarFallbackText }) => { 25 | return ( 26 | 27 | 28 | 34 | 35 | 36 | {user.email} 37 | 38 | 39 | 40 | 41 | 42 | Profile 43 | 44 | 45 | 46 | void signOut()} className="cursor-pointer"> 47 | 48 | Log out 49 | 50 | 51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/main-nav/main-nav-inner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { FC, forwardRef } from "react"; 5 | import { LogoIcon } from "~/components/icons"; 6 | import { cn } from "~/components/ui/lib/utils"; 7 | import { 8 | NavigationMenu, 9 | NavigationMenuContent, 10 | NavigationMenuItem, 11 | NavigationMenuLink, 12 | NavigationMenuList, 13 | NavigationMenuTrigger, 14 | } from "~/components/ui/navigation-menu"; 15 | import { siteConfig } from "~/config/site"; 16 | 17 | const ListItem = forwardRef, React.ComponentPropsWithoutRef>( 18 | ({ className, title, children, href, ...props }, _ref) => { 19 | return ( 20 |
  • 21 | 22 | 28 |
    {title}
    29 |

    {children}

    30 |
    31 | 32 |
  • 33 | ); 34 | }, 35 | ); 36 | ListItem.displayName = "ListItem"; 37 | 38 | export interface Props { 39 | user: boolean; 40 | } 41 | 42 | export const MainNavInner: FC = ({ user }) => { 43 | return ( 44 |
    45 | 46 | 47 | {siteConfig.name} 48 | 49 | 50 | 51 | {user && ( 52 | 53 | Posts 54 | 55 |
      56 |
    • 57 | 58 | 62 |
      {siteConfig.name}
      63 |

      {siteConfig.description}

      64 |
      65 | 66 |
    • 67 | 68 | Post a new Post. 69 | 70 |
    71 |
    72 |
    73 | )} 74 |
    75 |
    76 |
    77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/main-nav/main-nav.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | import { rsc } from "../../shared/server-rsc/trpc"; 3 | import { MainNavInner } from "./main-nav-inner"; 4 | 5 | /* @ts-expect-error Async Server Component */ 6 | export const MainNav: FC = async () => { 7 | const user = rsc.whoami.fetch(); 8 | 9 | return ; 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/mobile-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { Button } from "~/components/ui/button"; 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuGroup, 9 | DropdownMenuItem, 10 | DropdownMenuLabel, 11 | DropdownMenuSeparator, 12 | DropdownMenuTrigger, 13 | } from "~/components/ui/dropdown-menu"; 14 | import { ScrollArea } from "~/components/ui/scroll-area"; 15 | import { docsConfig } from "~/config/docs"; 16 | import { siteConfig } from "~/config/site"; 17 | import { LogoIcon } from "./icons"; 18 | import { cn } from "./ui/lib/utils"; 19 | 20 | export function MobileNav() { 21 | return ( 22 | 23 | 24 | 30 | 31 | 32 | 33 | 34 | {siteConfig.name} 35 | 36 | 37 | 38 | 39 | {docsConfig.sidebarNav?.map( 40 | (item, index) => 41 | item.href && ( 42 | 43 | {item.title} 44 | 45 | ), 46 | )} 47 | {docsConfig.sidebarNav.map((item, index) => ( 48 | 49 | 54 | {item.title} 55 | 56 | {item?.items?.length && 57 | item.items.map((item) => ( 58 | 59 | {item.href ? {item.title} : item.title} 60 | 61 | ))} 62 | 63 | ))} 64 | 65 | 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/posts-table.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { dehydrate, useQuery, useQueryClient } from "@tanstack/react-query"; 4 | import { 5 | ColumnDef, 6 | flexRender, 7 | getCoreRowModel, 8 | OnChangeFn, 9 | PaginationState, 10 | useReactTable, 11 | } from "@tanstack/react-table"; 12 | import { inferRouterOutputs } from "@trpc/server"; 13 | import { format } from "date-fns"; 14 | import Link from "next/link"; 15 | import { useMemo, useState, type FC } from "react"; 16 | import type { AppRouter } from "~/server/routers/_app"; 17 | import { api } from "~/trpc/client/trpc-client"; 18 | import { ChevronLeftIcon, ChevronRightIcon, SpinnerIcon } from "./icons"; 19 | import { Button } from "./ui/button"; 20 | import { cn } from "./ui/lib/utils"; 21 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; 22 | 23 | interface Row { 24 | title: string; 25 | 26 | created_at: Date; 27 | 28 | /** the slug of the file, not the full path to the file's page */ 29 | slug: string; 30 | } 31 | 32 | const convertDataToRow = async ( 33 | item: inferRouterOutputs["example"]["getInfinitePosts"]["items"][number], 34 | ) => { 35 | const row: Row = { 36 | title: item.title, 37 | slug: item.slug, 38 | created_at: item.created_at, 39 | }; 40 | return row; 41 | }; 42 | 43 | export interface Props { 44 | pageSizes: number[]; 45 | initialPageSize: number; 46 | } 47 | 48 | /** @todo option to sort ascending by time and ability to search, once the user has decrypted the file names */ 49 | export const PostsTable: FC = ({ pageSizes, initialPageSize }) => { 50 | // Dehydrate data that we fetched on the server in server components higher up the tree 51 | const queryClient = useQueryClient(); 52 | dehydrate(queryClient); 53 | 54 | const columns = useMemo[]>( 55 | () => [ 56 | { 57 | accessorKey: "title", 58 | id: "title", 59 | cell: (info) => info.getValue(), 60 | header: () => Name, 61 | footer: (props) => props.column.id, 62 | }, 63 | { 64 | accessorFn: (row) => format(row.created_at, "MMM d, yyyy, hh:mm a"), 65 | id: "created_at", 66 | cell: (info) => info.getValue(), 67 | header: () => Posted, 68 | footer: (props) => props.column.id, 69 | }, 70 | ], 71 | [], 72 | ); 73 | 74 | const [{ pageIndex, pageSize }, setPagination] = useState({ 75 | pageIndex: 0, 76 | pageSize: initialPageSize, 77 | }); 78 | 79 | const fetchDataOptions = { 80 | pageIndex, 81 | pageSize, 82 | }; 83 | 84 | const dataQuery = api.example.getInfinitePosts.useInfiniteQuery( 85 | { limit: fetchDataOptions.pageSize }, 86 | { getNextPageParam: (lastPage) => lastPage.nextCursor, refetchOnWindowFocus: false }, 87 | ); 88 | 89 | const onPaginationChange: OnChangeFn = (paginationState) => { 90 | void dataQuery.fetchNextPage().then(() => { 91 | setPagination(paginationState); 92 | }); 93 | }; 94 | 95 | const defaultData = useMemo(() => [], []); 96 | 97 | const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); 98 | 99 | const totalCount = (dataQuery.data?.pages[pagination.pageIndex]?.totalCount as number) ?? 0; 100 | const pageCount = useMemo(() => { 101 | return Math.ceil(totalCount / pageSize); 102 | }, [totalCount, pageSize]); 103 | 104 | // Use a dependent query to convert our fetched data into Rows. 105 | const pageData = dataQuery.data?.pages[pagination.pageIndex]?.items; 106 | const dataForPageQuery = useQuery( 107 | ["postsTableDataForPage", pageData], 108 | () => { 109 | if (!pageData) return; 110 | const promises = []; 111 | for (const item of pageData) { 112 | promises.push(convertDataToRow(item)); 113 | } 114 | return Promise.all(promises); 115 | }, 116 | { enabled: !!pageData }, 117 | ); 118 | 119 | const table = useReactTable({ 120 | data: dataForPageQuery.data ?? defaultData, 121 | columns, 122 | pageCount, 123 | state: { pagination }, 124 | onPaginationChange: onPaginationChange, 125 | getCoreRowModel: getCoreRowModel(), 126 | manualPagination: true, 127 | // getPaginationRowModel: getPaginationRowModel(), // If only doing manual pagination, you don't need this 128 | debugTable: true, 129 | }); 130 | 131 | return ( 132 | <> 133 | {/* table */} 134 |
    135 |
    136 |
    137 |
    138 | 139 | 140 | {table.getHeaderGroups().map((headerGroup) => ( 141 | 142 | {headerGroup.headers.map((header) => { 143 | return ( 144 | 158 | ); 159 | })} 160 | 161 | ))} 162 | 163 | 164 | {table.getRowModel().rows.map((row) => { 165 | return ( 166 | 167 | {row.getVisibleCells().map((cell) => { 168 | return ( 169 | 180 | ); 181 | })} 182 | 183 | ); 184 | })} 185 | 186 |
    150 |
    151 | {header.isPlaceholder ? null : ( 152 |
    153 | {flexRender(header.column.columnDef.header, header.getContext())} 154 |
    155 | )} 156 |
    157 |
    170 | 177 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 178 | 179 |
    187 |
    188 |
    189 |
    190 | 191 |
    192 | 193 | {/* pagination */} 194 |
    195 | {/* simplified mobile version */} 196 |
    197 |
    198 | 201 |
    202 | 205 |
    206 | 207 | {/* enhanced desktop version */} 208 |
    209 |
    210 | 211 | Page {table.getState().pagination.pageIndex + 1} of{" "} 212 | {table.getPageCount()} 213 | 214 | 234 |
    235 |
    236 |
    {dataQuery.isFetching ? : null}
    237 | 253 |
    254 |
    255 |
    256 |
    257 | 258 | ); 259 | }; 260 | -------------------------------------------------------------------------------- /src/components/sign-in-options.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FC } from "react"; 4 | import { signIn } from "~/auth/client"; 5 | import { GithubIcon, GoogleIcon } from "./icons"; 6 | import { Button } from "./ui/button"; 7 | 8 | const SignInButtons: FC = () => { 9 | return ( 10 |
    11 | 15 | 19 |
    20 | ); 21 | }; 22 | 23 | export default SignInButtons; 24 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 4 | import { ThemeProviderProps } from "next-themes/dist/types"; 5 | 6 | /** Wrap next-theme's provider in a client component so that we can use context */ 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 4 | import * as React from "react"; 5 | import { cn } from "./lib/utils"; 6 | 7 | const Avatar = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 16 | )); 17 | Avatar.displayName = AvatarPrimitive.Root.displayName; 18 | 19 | const AvatarImage = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )); 25 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 26 | 27 | const AvatarFallback = React.forwardRef< 28 | React.ElementRef, 29 | React.ComponentPropsWithoutRef 30 | >(({ className, ...props }, ref) => ( 31 | 39 | )); 40 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 41 | 42 | export { Avatar, AvatarImage, AvatarFallback }; 43 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cva, VariantProps } from "class-variance-authority"; 4 | import * as React from "react"; 5 | import { cn } from "./lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 dark:hover:bg-slate-800 dark:hover:text-slate-100 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900", 13 | destructive: "bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600", 14 | outline: "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100", 15 | subtle: "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100", 16 | ghost: 17 | "bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent", 18 | link: "bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent", 19 | }, 20 | size: { 21 | default: "h-10 py-2 px-4", 22 | sm: "h-9 px-2 rounded-md", 23 | lg: "h-11 px-8 rounded-md", 24 | }, 25 | }, 26 | defaultVariants: { 27 | variant: "default", 28 | size: "default", 29 | }, 30 | }, 31 | ); 32 | 33 | export interface ButtonProps 34 | extends React.ButtonHTMLAttributes, 35 | VariantProps {} 36 | 37 | const Button = React.forwardRef(({ className, variant, size, ...props }, ref) => { 38 | return