├── .nvmrc
├── public
├── robots.txt
├── site.webmanifest
└── favicon.ico
├── src
├── @trpc
│ └── next-layout
│ │ ├── server
│ │ ├── index.ts
│ │ ├── local-storage.ts
│ │ └── createTrpcNextLayout.tsx
│ │ └── client
│ │ ├── index.ts
│ │ ├── createHydrateClient.tsx
│ │ └── createTrpcNextBeta.tsx
├── styles
│ └── globals.css
├── types
│ └── common
│ │ ├── package.json
│ │ └── main.d.ts
├── app
│ ├── (auth)
│ │ ├── layout.tsx
│ │ ├── sign-in
│ │ │ └── page.tsx
│ │ └── sign-up
│ │ │ └── page.tsx
│ ├── hello-from-client.tsx
│ ├── client-providers.tsx
│ ├── dashboard
│ │ ├── loading.tsx
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── note-card.tsx
│ │ ├── create-note.tsx
│ │ └── edit-note.tsx
│ ├── layout.tsx
│ └── page.tsx
├── lib
│ ├── api
│ │ ├── types.ts
│ │ ├── server.ts
│ │ └── client.ts
│ └── utils.ts
├── server
│ ├── api
│ │ ├── root.ts
│ │ ├── context.ts
│ │ ├── trpc.ts
│ │ └── routers
│ │ │ └── example.ts
│ └── db
│ │ ├── index.ts
│ │ └── schema.ts
├── components
│ ├── feature-card.tsx
│ ├── ui
│ │ ├── label.tsx
│ │ ├── input.tsx
│ │ ├── textarea.tsx
│ │ ├── button.tsx
│ │ └── dialog.tsx
│ └── typography
│ │ └── index.tsx
├── config
│ └── site.ts
├── pages
│ └── api
│ │ └── trpc
│ │ └── [trpc].ts
├── middleware.ts
└── env.mjs
├── .prettierignore
├── postcss.config.cjs
├── .editorconfig
├── .env.example
├── drizzle.config.ts
├── next.config.mjs
├── .vscode
└── settings.json
├── patches
└── @tanstack__react-query@4.14.5.patch
├── .gitignore
├── tailwind.config.ts
├── prettier.config.cjs
├── tsconfig.json
├── .eslintrc.cjs
├── README.md
└── package.json
/.nvmrc:
--------------------------------------------------------------------------------
1 | v16.18.0
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # *
2 | User-agent: *
3 | Allow: /
--------------------------------------------------------------------------------
/src/@trpc/next-layout/server/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./createTrpcNextLayout";
2 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .next
2 | dist
3 | node_modules
4 | .env
5 | .env.example
6 | pnpm-lock.yaml
7 | README.md
8 | next-env.d.ts
--------------------------------------------------------------------------------
/src/@trpc/next-layout/client/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./createTrpcNextBeta";
2 | export * from "./createHydrateClient";
3 |
--------------------------------------------------------------------------------
/src/types/common/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "common",
3 | "version": "1.0.0",
4 | "typings": "main.d.ts"
5 | }
6 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
8 | module.exports = config;
9 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | end_of_line = lf
7 | indent_size = 2
8 | indent_style = space
9 | insert_final_newline = true
10 | trim_trailing_whitespace = true
--------------------------------------------------------------------------------
/src/app/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | export default function Layout({ children }: PropsWithChildren) {
2 | return (
3 |
4 | {children}
5 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # App
2 | NEXT_PUBLIC_APP_URL=http://localhost:3000
3 |
4 | # Authentication (Clerk)
5 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
6 | CLERK_SECRET_KEY=
7 |
8 | #Database
9 | DB_HOST=
10 | DB_USERNAME=
11 | DB_PASSWORD=
12 | DB_URL=
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import type { Config } from "drizzle-kit";
3 |
4 | const config: Config = {
5 | schema: "./src/server/db/schema.ts",
6 | connectionString: process.env.DB_URL,
7 | };
8 |
9 | export default config;
10 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Next App Router with TRPC and Drizzle on Edge",
3 | "short_name": "Next App Router with TRPC and Drizzle on Edge",
4 | "theme_color": "#ffffff",
5 | "background_color": "#ffffff",
6 | "display": "standalone"
7 | }
8 |
--------------------------------------------------------------------------------
/src/types/common/main.d.ts:
--------------------------------------------------------------------------------
1 | type PropsWithChildren = P & { children?: ReactNode };
2 | type PropsWithClassName
= P & { className?: string };
3 | type PropsWithChildrenAndClassName
= PropsWithClassName
&
4 | PropsWithChildren
;
5 |
--------------------------------------------------------------------------------
/src/lib/api/types.ts:
--------------------------------------------------------------------------------
1 | import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
2 | import { type AppRouter } from "~/server/api/root";
3 |
4 | export type RouterOutputs = inferRouterOutputs;
5 | export type RouterInputs = inferRouterInputs;
6 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignIn } from "@clerk/nextjs/app-beta";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
7 | export const runtime = "experimental-edge";
8 | export const revalidate = 0;
9 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignUp } from "@clerk/nextjs/app-beta";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
7 | export const runtime = "experimental-edge";
8 | export const revalidate = 0;
9 |
--------------------------------------------------------------------------------
/src/server/api/root.ts:
--------------------------------------------------------------------------------
1 | import { exampleRouter } from "~/server/api/routers/example";
2 | import { createTRPCRouter } from "~/server/api/trpc";
3 |
4 | export const appRouter = createTRPCRouter({
5 | example: exampleRouter,
6 | });
7 |
8 | // export type definition of API
9 | export type AppRouter = typeof appRouter;
10 |
--------------------------------------------------------------------------------
/src/app/hello-from-client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { api } from "~/lib/api/client";
4 |
5 | export default function HelloFromClient() {
6 | const { data, isLoading } = api.example.hello.useQuery({
7 | text: "Test Client TRPC Call",
8 | });
9 |
10 | if (isLoading) return <>Loading...>;
11 | if (!data) return <>Error>;
12 |
13 | return <>{data.greeting}>;
14 | }
15 |
--------------------------------------------------------------------------------
/src/server/db/index.ts:
--------------------------------------------------------------------------------
1 | import { connect } from "@planetscale/database";
2 | import { drizzle } from "drizzle-orm/planetscale-serverless";
3 | import { env } from "~/env.mjs";
4 |
5 | const config = {
6 | host: env.DB_HOST,
7 | username: env.DB_USERNAME,
8 | password: env.DB_PASSWORD,
9 | };
10 |
11 | const connection = connect(config);
12 |
13 | export const db = drizzle(connection);
14 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /**
2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
3 | * This is especially useful for Docker builds.
4 | */
5 | !process.env.SKIP_ENV_VALIDATION && (await import("./src/env.mjs"));
6 |
7 | /** @type {import("next").NextConfig} */
8 | const config = {
9 | reactStrictMode: true,
10 | experimental: { appDir: true, typedRoutes: true },
11 | };
12 | export default config;
13 |
--------------------------------------------------------------------------------
/src/app/client-providers.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ClerkProvider } from "@clerk/nextjs/app-beta/client";
4 | import { env } from "~/env.mjs";
5 | import { api } from "~/lib/api/client";
6 |
7 | export function ClientProviders({ children }: PropsWithChildren) {
8 | return (
9 |
10 | {children}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnPaste": true,
3 | "editor.formatOnSave": true,
4 | "editor.defaultFormatter": "esbenp.prettier-vscode",
5 | "editor.codeActionsOnSave": {
6 | "source.fixAll.eslint": true,
7 | "source.fixAll.format": true
8 | },
9 | "css.lint.unknownAtRules": "ignore",
10 | "typescript.tsdk": "node_modules/.pnpm/typescript@5.0.4/node_modules/typescript/lib",
11 | "typescript.enablePromptUseWorkspaceTsdk": true
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/feature-card.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | title: string;
3 | description: string;
4 | href: string;
5 | };
6 |
7 | export default function FeatureCard({ title, description, href }: Props) {
8 | return (
9 |
15 | {title}
16 | {description}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/server/db/schema.ts:
--------------------------------------------------------------------------------
1 | import {
2 | serial,
3 | text,
4 | timestamp,
5 | varchar,
6 | } from "drizzle-orm/mysql-core/columns";
7 | import { mysqlTable } from "drizzle-orm/mysql-core/table";
8 |
9 | export const notes = mysqlTable("notes", {
10 | id: serial("id").primaryKey(),
11 | user_id: varchar("user_id", { length: 191 }).notNull(),
12 | slug: varchar("slug", { length: 191 }).notNull(),
13 | title: text("title").notNull(),
14 | text: text("text").default(""),
15 | created_at: timestamp("created_at").notNull().defaultNow().onUpdateNow(),
16 | });
17 |
--------------------------------------------------------------------------------
/src/lib/api/server.ts:
--------------------------------------------------------------------------------
1 | import { auth as getAuth } from "@clerk/nextjs/app-beta";
2 | import superjson from "superjson";
3 | import { createTRPCNextLayout } from "~/@trpc/next-layout/server";
4 |
5 | import "server-only";
6 | import { createContextInner } from "~/server/api/context";
7 | import { appRouter } from "~/server/api/root";
8 |
9 | export const api = createTRPCNextLayout({
10 | router: appRouter,
11 | transformer: superjson,
12 | createContext() {
13 | const auth = getAuth();
14 | return createContextInner({
15 | auth,
16 | req: null,
17 | });
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/src/config/site.ts:
--------------------------------------------------------------------------------
1 | export type SiteConfig = {
2 | name: string;
3 | description: string;
4 | url: string;
5 | links: {
6 | twitter: string;
7 | github: string;
8 | };
9 | };
10 |
11 | export const siteConfig: SiteConfig = {
12 | name: "Next App Router with TRPC and Drizzle on Edge",
13 | description:
14 | "T3 stack using app router with TRPC on edge, Drizzle Orm with PlanetScale serverless driver",
15 | url: "https://giga-stack.vercel.app",
16 | links: {
17 | twitter: "https://twitter.com/o_ploskovytskyy",
18 | github:
19 | "https://github.com/ploskovytskyy/next-app-router-trpc-drizzle-planetscale-edge",
20 | },
21 | };
22 |
--------------------------------------------------------------------------------
/src/app/dashboard/loading.tsx:
--------------------------------------------------------------------------------
1 | export default function Loading() {
2 | return (
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as LabelPrimitive from "@radix-ui/react-label";
5 | import { cn } from "~/lib/utils";
6 |
7 | const Label = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 | ));
20 | Label.displayName = LabelPrimitive.Root.displayName;
21 |
22 | export { Label };
23 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import slugifyjs from "slugify";
3 | import { twMerge } from "tailwind-merge";
4 | import { env } from "~/env.mjs";
5 |
6 | export function cn(...inputs: ClassValue[]) {
7 | return twMerge(clsx(inputs));
8 | }
9 |
10 | export function formatDate(input: string | number): string {
11 | const date = new Date(input);
12 | return date.toLocaleDateString("en-US", {
13 | month: "long",
14 | day: "numeric",
15 | year: "numeric",
16 | });
17 | }
18 |
19 | export function absoluteUrl(path: string) {
20 | return `${env.NEXT_PUBLIC_APP_URL}${path}`;
21 | }
22 |
23 | export function slugify(string: string) {
24 | return slugifyjs(string, { lower: true });
25 | }
26 |
--------------------------------------------------------------------------------
/.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 | # database
12 | /prisma/db.sqlite
13 | /prisma/db.sqlite-journal
14 |
15 | # next.js
16 | /.next/
17 | /out/
18 | next-env.d.ts
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # local env files
34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
35 | .env
36 | .env*.local
37 |
38 | # vercel
39 | .vercel
40 |
41 | # typescript
42 | *.tsbuildinfo
43 |
--------------------------------------------------------------------------------
/src/@trpc/next-layout/client/createHydrateClient.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useMemo } from "react";
4 | import { Hydrate, type DehydratedState } from "@tanstack/react-query";
5 | import { type DataTransformer } from "@trpc/server";
6 |
7 | export function createHydrateClient(opts: { transformer?: DataTransformer }) {
8 | return function HydrateClient(props: {
9 | children: React.ReactNode;
10 | state: DehydratedState;
11 | }) {
12 | const { state, children } = props;
13 |
14 | const transformedState: DehydratedState = useMemo(() => {
15 | if (opts.transformer) {
16 | return opts.transformer.deserialize(state) as DehydratedState;
17 | }
18 | return state;
19 | }, [state]);
20 |
21 | return {children} ;
22 | };
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/dashboard/layout.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { UserButton } from "@clerk/nextjs/app-beta";
3 | import { Home } from "lucide-react";
4 |
5 | export default function Layout({ children }: PropsWithChildren) {
6 | return (
7 |
8 |
9 |
Dashboard
10 |
11 |
15 |
16 |
17 |
18 |
19 |
20 | {children}
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/@trpc/next-layout/server/local-storage.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-return */
2 | /**
3 | * This file makes sure that we can get a storage that is unique to the current request context
4 | */
5 |
6 | import { type AsyncLocalStorage } from "async_hooks";
7 | import { requestAsyncStorage as asyncStorage } from "next/dist/client/components/request-async-storage";
8 |
9 | function throwError(msg: string) {
10 | throw new Error(msg);
11 | }
12 |
13 | export function getRequestStorage(): T {
14 | if ("getStore" in asyncStorage) {
15 | return (
16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
17 | (asyncStorage as AsyncLocalStorage).getStore() ??
18 | throwError("Couldn't get async storage")
19 | );
20 | }
21 |
22 | return asyncStorage as T;
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cn } from "~/lib/utils";
3 |
4 | export type InputProps = React.InputHTMLAttributes;
5 |
6 | const Input = React.forwardRef(
7 | ({ className, ...props }, ref) => {
8 | return (
9 |
17 | );
18 | }
19 | );
20 | Input.displayName = "Input";
21 |
22 | export { Input };
23 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cn } from "~/lib/utils";
3 |
4 | export type TextareaProps = React.TextareaHTMLAttributes;
5 |
6 | const Textarea = React.forwardRef(
7 | ({ className, ...props }, ref) => {
8 | return (
9 |
17 | );
18 | }
19 | );
20 | Textarea.displayName = "Textarea";
21 |
22 | export { Textarea };
23 |
--------------------------------------------------------------------------------
/src/pages/api/trpc/[trpc].ts:
--------------------------------------------------------------------------------
1 | import { type NextRequest } from "next/server";
2 | import { getAuth } from "@clerk/nextjs/server";
3 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
4 | import { env } from "~/env.mjs";
5 | import { createContextInner } from "~/server/api/context";
6 | import { appRouter } from "~/server/api/root";
7 |
8 | export default function handler(req: NextRequest) {
9 | return fetchRequestHandler({
10 | req,
11 | endpoint: "/api/trpc",
12 | router: appRouter,
13 | createContext() {
14 | const auth = getAuth(req);
15 | return createContextInner({
16 | req,
17 | auth,
18 | });
19 | },
20 | onError:
21 | env.NODE_ENV === "development"
22 | ? ({ path, error }) => {
23 | console.error(
24 | `❌ tRPC failed on ${path ?? ""}: ${error.message}`
25 | );
26 | }
27 | : undefined,
28 | });
29 | }
30 |
31 | export const runtime = "experimental-edge";
32 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { type Config } from "tailwindcss";
2 | import { fontFamily } from "tailwindcss/defaultTheme";
3 |
4 | export default {
5 | content: ["./src/**/*.{js,ts,jsx,tsx}"],
6 | theme: {
7 | extend: {
8 | container: {
9 | center: true,
10 | padding: "1.5rem",
11 | },
12 | fontFamily: {
13 | sans: ["var(--font-sans)", ...fontFamily.sans],
14 | },
15 | keyframes: {
16 | "accordion-down": {
17 | from: { height: "0" },
18 | to: { height: "var(--radix-accordion-content-height)" },
19 | },
20 | "accordion-up": {
21 | from: { height: "var(--radix-accordion-content-height)" },
22 | to: { height: "0" },
23 | },
24 | },
25 | animation: {
26 | "accordion-down": "accordion-down 0.2s ease-out",
27 | "accordion-up": "accordion-up 0.2s ease-out",
28 | },
29 | },
30 | },
31 | plugins: [require("tailwindcss-animate")],
32 | } satisfies Config;
33 |
--------------------------------------------------------------------------------
/src/app/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { H2, H3 } from "~/components/typography";
2 | import { api } from "~/lib/api/server";
3 |
4 | import CreateNote from "./create-note";
5 | import NoteCard from "./note-card";
6 |
7 | export default async function Page() {
8 | const notes = await api.example.getCurrentUserNotes.fetch();
9 | return (
10 | <>
11 |
12 | My notes
13 |
14 |
15 | {!notes.length && (
16 |
17 |
Create my first note
18 |
19 |
20 | )}
21 |
22 | {notes.map(({ id, title, text }) => (
23 |
24 | ))}
25 |
26 | >
27 | );
28 | }
29 |
30 | export const runtime = "experimental-edge";
31 | export const revalidate = 0;
32 |
--------------------------------------------------------------------------------
/prettier.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import("prettier").Config} */
2 | /** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */
3 |
4 | const config = {
5 | endOfLine: "lf",
6 | semi: true,
7 | singleQuote: false,
8 | tabWidth: 2,
9 | trailingComma: "es5",
10 | importOrder: [
11 | "^(react/(.*)$)|^(react$)",
12 | "^(next/(.*)$)|^(next$)",
13 | "",
14 | "",
15 | "^types$",
16 | "^@local/(.*)$",
17 | "^@/config/(.*)$",
18 | "^@/lib/(.*)$",
19 | "^@/components/(.*)$",
20 | "^@/styles/(.*)$",
21 | "^[./]",
22 | ],
23 | importOrderSeparation: false,
24 | importOrderSortSpecifiers: true,
25 | importOrderBuiltinModulesToTop: true,
26 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"],
27 | importOrderMergeDuplicateImports: true,
28 | importOrderCombineTypeAndValueImports: true,
29 | plugins: [
30 | "prettier-plugin-tailwindcss",
31 | "@ianvs/prettier-plugin-sort-imports",
32 | ],
33 | };
34 |
35 | module.exports = config;
36 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse, type NextRequest } from "next/server";
2 | import { getAuth, withClerkMiddleware } from "@clerk/nextjs/server";
3 |
4 | const publicPaths = ["/", "/sign-in*", "/sign-up*", "/api/trpc*"];
5 |
6 | const isPublic = (reqPath: string) => {
7 | return publicPaths.find((publicPath) =>
8 | reqPath.match(new RegExp(`^${publicPath}$`.replace("*$", "($|/)")))
9 | );
10 | };
11 |
12 | export default withClerkMiddleware((request: NextRequest) => {
13 | if (isPublic(request.nextUrl.pathname)) {
14 | return NextResponse.next();
15 | }
16 |
17 | const { userId } = getAuth(request);
18 |
19 | if (!userId) {
20 | const signInUrl = new URL("/sign-in", request.url);
21 | signInUrl.searchParams.set("redirect_url", request.url);
22 | return NextResponse.redirect(signInUrl);
23 | }
24 |
25 | return NextResponse.next();
26 | });
27 |
28 | // Stop Middleware running on static files and public folder
29 | export const config = {
30 | matcher: "/((?!_next/image|_next/static|favicon.ico|site.webmanifest).*)",
31 | };
32 |
--------------------------------------------------------------------------------
/src/server/api/context.ts:
--------------------------------------------------------------------------------
1 | import type { GetServerSidePropsContext } from "next";
2 | import type { NextRequest } from "next/server";
3 | import type {
4 | SignedInAuthObject,
5 | SignedOutAuthObject,
6 | } from "@clerk/nextjs/dist/api";
7 | import { getAuth } from "@clerk/nextjs/server";
8 | import type { inferAsyncReturnType } from "@trpc/server";
9 | import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
10 | import { db } from "~/server/db";
11 |
12 | type CreateContextOptions = {
13 | auth: SignedInAuthObject | SignedOutAuthObject | null;
14 | req: NextRequest | GetServerSidePropsContext["req"] | null;
15 | };
16 |
17 | export const createContextInner = (opts: CreateContextOptions) => {
18 | return {
19 | auth: opts.auth,
20 | req: opts.req,
21 | db,
22 | };
23 | };
24 |
25 | export const createContext = (opts: CreateNextContextOptions) => {
26 | const auth = getAuth(opts.req);
27 | return createContextInner({
28 | auth,
29 | req: opts.req,
30 | });
31 | };
32 |
33 | export type Context = inferAsyncReturnType;
34 |
--------------------------------------------------------------------------------
/src/server/api/trpc.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError, initTRPC } from "@trpc/server";
2 | import superjson from "superjson";
3 | import { ZodError } from "zod";
4 |
5 | import { type Context } from "./context";
6 |
7 | const t = initTRPC.context().create({
8 | transformer: superjson,
9 | errorFormatter({ shape, error }) {
10 | return {
11 | ...shape,
12 | data: {
13 | ...shape.data,
14 | zodError:
15 | error.cause instanceof ZodError ? error.cause.flatten() : null,
16 | },
17 | };
18 | },
19 | });
20 |
21 | const isAuthed = t.middleware(async ({ ctx, next }) => {
22 | if (!ctx.auth?.userId) {
23 | throw new TRPCError({
24 | code: "UNAUTHORIZED",
25 | message: "Not authenticated",
26 | });
27 | }
28 |
29 | return next({
30 | ctx: {
31 | ...ctx,
32 | auth: ctx.auth,
33 | },
34 | });
35 | });
36 |
37 | export const createTRPCRouter = t.router;
38 | export const publicProcedure = t.procedure;
39 | export const protectedProcedure = t.procedure.use(isAuthed);
40 | //TODO: add role/permissions based procedures
41 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "checkJs": true,
11 | "skipLibCheck": true,
12 | "strict": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "noEmit": true,
15 | "esModuleInterop": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "jsx": "preserve",
21 | "incremental": true,
22 | "noUncheckedIndexedAccess": true,
23 | "baseUrl": ".",
24 | "paths": {
25 | "~/*": [
26 | "./src/*"
27 | ]
28 | },
29 | "typeRoots": [
30 | "src/types"
31 | ],
32 | "plugins": [
33 | {
34 | "name": "next"
35 | }
36 | ]
37 | },
38 | "include": [
39 | ".eslintrc.cjs",
40 | "next-env.d.ts",
41 | "**/*.ts",
42 | "**/*.tsx",
43 | "**/*.cjs",
44 | "**/*.mjs",
45 | ".next/types/**/*.ts"
46 | ],
47 | "exclude": [
48 | "node_modules"
49 | ]
50 | }
51 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const path = require("path");
3 |
4 | /** @type {import("eslint").Linter.Config} */
5 | const config = {
6 | overrides: [
7 | {
8 | extends: [
9 | "plugin:@typescript-eslint/recommended-requiring-type-checking",
10 | ],
11 | files: ["*.ts", "*.tsx"],
12 | parserOptions: {
13 | project: path.join(__dirname, "tsconfig.json"),
14 | },
15 | },
16 | ],
17 | parser: "@typescript-eslint/parser",
18 | parserOptions: {
19 | project: path.join(__dirname, "tsconfig.json"),
20 | },
21 | plugins: ["@typescript-eslint"],
22 | extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"],
23 | rules: {
24 | "@typescript-eslint/consistent-type-imports": [
25 | "warn",
26 | {
27 | prefer: "type-imports",
28 | fixStyle: "inline-type-imports",
29 | },
30 | ],
31 | "@typescript-eslint/no-misused-promises": [
32 | 2,
33 | {
34 | checksVoidReturn: {
35 | attributes: false,
36 | },
37 | },
38 | ],
39 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
40 | },
41 | };
42 |
43 | module.exports = config;
44 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # Giga Stack ✨
3 |
4 | Inspired by [T3 stack](https://create.t3.gg/) and [Shadcn Taxonomy](https://tx.shadcn.com/) project, I created this example of experimental edge stack full of sweet and fancy features for you to play with. Shout out to a bunch of open source solutions on the GitHub which I combined here.
5 |
6 | [deployed demo](https://giga-stack.vercel.app/)
7 |
8 | ## Features
9 |
10 | - ``Next.js`` 13 app dir (edge runtime)
11 | - ``tRPC``
12 | - ``Clerk`` Auth
13 | - ``Drizzle`` ORM + ``PlanetScale`` serverless js driver
14 | - ``Shadcn/ui`` (Radix UI + Tailwind)
15 |
16 |
17 | ## Try it yourself
18 |
19 | Install dependencies using pnpm:
20 | ```bash
21 | pnpm install
22 | ```
23 |
24 | Copy .env.example to .env and update the variables:
25 | ```bash
26 | cp .env.example .env
27 | ```
28 |
29 | Push schema to PlanetScale:
30 | ```bash
31 | pnpm db:push
32 | ```
33 |
34 | Have fun
35 | ```bash
36 | pnpm dev
37 | ```
38 |
39 |
40 |
41 | ## Resources
42 |
43 | - [next add dir + trpc](https://github.com/trpc/next-13)
44 | - [shadcn/taxonomy](https://github.com/shadcn/taxonomy/blob/main/README.md)
45 | - [mattddean](https://github.com/mattddean/t3-app-router-edge-drizzle)
46 | - [solaldunckel](https://github.com/solaldunckel/next-13-app-router-with-trpc)
47 | - [og create-t3-app](https://github.com/t3-oss/create-t3-app)
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/app/dashboard/note-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 | import { Loader2 } from "lucide-react";
5 | import { H3 } from "~/components/typography";
6 | import { Button } from "~/components/ui/button";
7 | import { api } from "~/lib/api/client";
8 | import { type RouterOutputs } from "~/lib/api/types";
9 |
10 | import EditNote from "./edit-note";
11 |
12 | type Props = Pick<
13 | RouterOutputs["example"]["getCurrentUserNotes"][0],
14 | "id" | "title" | "text"
15 | >;
16 |
17 | export default function NoteCard({ id, title, text }: Props) {
18 | const router = useRouter();
19 | const { mutate: deleteNote, isLoading: isDeleting } =
20 | api.example.deleteNote.useMutation({ onSuccess: () => router.refresh() });
21 | return (
22 |
23 |
24 |
{title}
25 |
{text}
26 |
27 |
28 |
29 | deleteNote({ id })}
33 | >
34 | Delete
35 | {isDeleting && }
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/lib/api/client.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { httpBatchLink, loggerLink } from "@trpc/client";
4 | import superjson from "superjson";
5 | import {
6 | createHydrateClient,
7 | createTRPCNextBeta,
8 | } from "~/@trpc/next-layout/client";
9 | import { type AppRouter } from "~/server/api/root";
10 |
11 | const getBaseUrl = () => {
12 | if (typeof window !== "undefined") return ""; // browser should use relative url
13 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url
14 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
15 | };
16 |
17 | /*
18 | * Create a client that can be used in the client only
19 | */
20 |
21 | export const api = createTRPCNextBeta({
22 | transformer: superjson,
23 | queryClientConfig: {
24 | defaultOptions: {
25 | queries: {
26 | refetchOnWindowFocus: false,
27 | refetchInterval: false,
28 | retry: false,
29 | cacheTime: Infinity,
30 | staleTime: Infinity,
31 | },
32 | },
33 | },
34 | links: [
35 | loggerLink({
36 | enabled: (opts) =>
37 | process.env.NODE_ENV === "development" ||
38 | (opts.direction === "down" && opts.result instanceof Error),
39 | }),
40 | httpBatchLink({
41 | url: `${getBaseUrl()}/api/trpc`,
42 | }),
43 | ],
44 | });
45 |
46 | /*
47 | * A component used to hydrate the state from server to client
48 | */
49 |
50 | export const HydrateClient = createHydrateClient({
51 | transformer: superjson,
52 | });
53 |
--------------------------------------------------------------------------------
/src/server/api/routers/example.ts:
--------------------------------------------------------------------------------
1 | import { desc, eq } from "drizzle-orm";
2 | import { z } from "zod";
3 | import { createNoteSchema } from "~/app/dashboard/create-note";
4 | import { editNoteSchema } from "~/app/dashboard/edit-note";
5 | import { slugify } from "~/lib/utils";
6 | import {
7 | createTRPCRouter,
8 | protectedProcedure,
9 | publicProcedure,
10 | } from "~/server/api/trpc";
11 | import { notes } from "~/server/db/schema";
12 |
13 | export const exampleRouter = createTRPCRouter({
14 | hello: publicProcedure
15 | .input(z.object({ text: z.string() }))
16 | .query(({ input }) => {
17 | return {
18 | greeting: `Hello ${input.text}`,
19 | };
20 | }),
21 | getCurrentUserNotes: protectedProcedure.query(({ ctx: { auth, db } }) => {
22 | const data = db
23 | .select()
24 | .from(notes)
25 | .where(eq(notes.user_id, auth.userId))
26 | .orderBy(desc(notes.created_at));
27 | return data;
28 | }),
29 | createNote: protectedProcedure
30 | .input(createNoteSchema)
31 | .mutation(async ({ input, ctx: { auth, db } }) => {
32 | const result = await db.insert(notes).values({
33 | user_id: auth.userId,
34 | title: input.title,
35 | text: input.text,
36 | slug: slugify(input.title),
37 | });
38 | return result;
39 | }),
40 | deleteNote: protectedProcedure
41 | .input(
42 | z.object({
43 | id: z.number(),
44 | })
45 | )
46 | .mutation(async ({ input, ctx: { db } }) => {
47 | const result = await db.delete(notes).where(eq(notes.id, input.id));
48 | return result;
49 | }),
50 | editNote: protectedProcedure
51 | .input(editNoteSchema)
52 | .mutation(async ({ input, ctx: { db } }) => {
53 | const result = await db
54 | .update(notes)
55 | .set({
56 | title: input.title,
57 | text: input.text,
58 | })
59 | .where(eq(notes.id, input.id));
60 | return result;
61 | }),
62 | });
63 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-app-router-trpc-drizzle-planetscale-edge",
3 | "version": "0.1.0",
4 | "private": true,
5 | "author": {
6 | "name": "Oleksandr Ploskovytskyy",
7 | "url": "https://twitter.com/o_ploskovytskyy"
8 | },
9 | "scripts": {
10 | "build": "next build",
11 | "dev": "next dev",
12 | "lint": "next lint",
13 | "start": "next start",
14 | "prettier:fix": "prettier --write .",
15 | "prettier:check": "prettier --check .",
16 | "check-types": "tsc --pretty --noEmit",
17 | "check-lint": "eslint . --ext ts --ext tsx --ext js",
18 | "check-all": "pnpm prettier:check && pnpm check-lint && pnpm check-types",
19 | "db:push": "pnpm drizzle-kit push:mysql"
20 | },
21 | "dependencies": {
22 | "@clerk/nextjs": "^4.16.2",
23 | "@hookform/resolvers": "^3.0.1",
24 | "@ianvs/prettier-plugin-sort-imports": "^3.7.2",
25 | "@planetscale/database": "^1.7.0",
26 | "@radix-ui/react-dialog": "^1.0.3",
27 | "@radix-ui/react-label": "^2.0.1",
28 | "@tanstack/query-core": "4.14.5",
29 | "@tanstack/react-query": "4.14.5",
30 | "@trpc/client": "^10.20.0",
31 | "@trpc/next": "^10.20.0",
32 | "@trpc/react-query": "^10.20.0",
33 | "@trpc/server": "^10.20.0",
34 | "class-variance-authority": "^0.5.2",
35 | "clsx": "^1.2.1",
36 | "drizzle-orm": "^0.23.13",
37 | "lucide-react": "^0.145.0",
38 | "next": "^13.2.4",
39 | "react": "18.2.0",
40 | "react-dom": "18.2.0",
41 | "react-hook-form": "^7.43.9",
42 | "server-only": "^0.0.1",
43 | "slugify": "^1.6.6",
44 | "superjson": "^1.12.2",
45 | "tailwind-merge": "^1.12.0",
46 | "tailwindcss-animate": "^1.0.5",
47 | "zod": "^3.21.4"
48 | },
49 | "devDependencies": {
50 | "@types/eslint": "^8.21.3",
51 | "@types/node": "^18.15.5",
52 | "@types/prettier": "^2.7.2",
53 | "@types/react": "^18.0.28",
54 | "@types/react-dom": "^18.0.11",
55 | "@typescript-eslint/eslint-plugin": "^5.56.0",
56 | "@typescript-eslint/parser": "^5.56.0",
57 | "autoprefixer": "^10.4.14",
58 | "dotenv": "^16.0.3",
59 | "drizzle-kit": "0.17.1-5df459e",
60 | "eslint": "^8.36.0",
61 | "eslint-config-next": "^13.2.4",
62 | "postcss": "^8.4.21",
63 | "prettier": "^2.8.6",
64 | "prettier-plugin-tailwindcss": "^0.2.6",
65 | "tailwindcss": "^3.3.0",
66 | "typescript": "^5.0.2"
67 | },
68 | "ct3aMetadata": {
69 | "initVersion": "7.10.3"
70 | },
71 | "pnpm": {
72 | "patchedDependencies": {
73 | "@tanstack/react-query@4.14.5": "patches/@tanstack__react-query@4.14.5.patch"
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 | import { Loader2 } from "lucide-react";
4 | import { cn } from "~/lib/utils";
5 |
6 | const buttonVariants = cva(
7 | "active:scale-95 inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-stone-400 focus:ring-offset-2 dark:hover:bg-stone-800 dark:hover:text-stone-100 disabled:opacity-50 dark:focus:ring-stone-400 disabled:pointer-events-none dark:focus:ring-offset-stone-900 data-[state=open]:bg-stone-100 dark:data-[state=open]:bg-stone-800",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "bg-rose-500 text-white hover:bg-rose-600 dark:hover:bg-rose-600",
13 | destructive:
14 | "bg-white dark:bg-stone-900 border border-red-600 text-red-600 dark:text-white hover:text-white hover:bg-red-600 dark:hover:bg-red-600",
15 | outline:
16 | "bg-transparent border border-stone-200 hover:bg-stone-100 dark:border-stone-700 dark:text-stone-100",
17 | subtle:
18 | "bg-stone-100 text-stone-900 hover:bg-stone-200 dark:bg-stone-700 dark:text-stone-100",
19 | ghost:
20 | "bg-transparent hover:bg-stone-100 dark:hover:bg-stone-800 dark:text-stone-100 dark:hover:text-stone-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent",
21 | link: "bg-transparent dark:bg-transparent underline-offset-4 hover:underline text-stone-900 dark:text-stone-100 hover:bg-transparent dark:hover:bg-transparent",
22 | },
23 | size: {
24 | default: "h-10 py-2 px-4",
25 | sm: "h-9 px-2 rounded-md",
26 | lg: "h-11 px-8 rounded-md",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | loading?: boolean;
40 | loadingText?: string;
41 | }
42 |
43 | const Button = React.forwardRef(
44 | (
45 | { className, variant, size, loading, loadingText, children, ...props },
46 | ref
47 | ) => {
48 | return (
49 |
54 | {loading && }
55 | {loading && loadingText ? loadingText : children}
56 |
57 | );
58 | }
59 | );
60 | Button.displayName = "Button";
61 |
62 | export { Button, buttonVariants };
63 |
--------------------------------------------------------------------------------
/src/components/typography/index.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "~/lib/utils";
2 |
3 | export function H1({ children, className }: PropsWithChildrenAndClassName) {
4 | return (
5 |
11 | {children}
12 |
13 | );
14 | }
15 |
16 | export function H2({ children, className }: PropsWithChildrenAndClassName) {
17 | return (
18 |
24 | {children}
25 |
26 | );
27 | }
28 |
29 | export function H3({ children, className }: PropsWithChildrenAndClassName) {
30 | return (
31 |
34 | {children}
35 |
36 | );
37 | }
38 |
39 | export function H4({ children, className }: PropsWithChildrenAndClassName) {
40 | return (
41 |
44 | {children}
45 |
46 | );
47 | }
48 |
49 | export function P({ children, className }: PropsWithChildrenAndClassName) {
50 | return (
51 |
52 | {children}
53 |
54 | );
55 | }
56 |
57 | export function Lead({ children, className }: PropsWithChildrenAndClassName) {
58 | return {children}
;
59 | }
60 |
61 | export function Large({ children, className }: PropsWithChildrenAndClassName) {
62 | return (
63 |
64 | {children}
65 |
66 | );
67 | }
68 |
69 | export function Small({ children, className }: PropsWithChildrenAndClassName) {
70 | return (
71 |
72 | {children}
73 |
74 | );
75 | }
76 |
77 | export function Subtle({ children, className }: PropsWithChildrenAndClassName) {
78 | return (
79 |
80 | {children}
81 |
82 | );
83 | }
84 |
85 | export function Code({ children, className }: PropsWithChildrenAndClassName) {
86 | return (
87 |
93 | {children}
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/env.mjs:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | const server = z.object({
4 | NODE_ENV: z.enum(["development", "test", "production"]),
5 | CLERK_SECRET_KEY: z.string().min(1),
6 | DB_HOST: z.string().min(1),
7 | DB_USERNAME: z.string().min(1),
8 | DB_PASSWORD: z.string().min(1),
9 | DB_URL: z.string().min(1),
10 | });
11 |
12 | const client = z.object({
13 | NEXT_PUBLIC_APP_URL: z.string().min(1),
14 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
15 | });
16 |
17 | /**
18 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
19 | * middlewares) or client-side so we need to destruct manually.
20 | *
21 | * @type {Record | keyof z.infer, string | undefined>}
22 | */
23 | const processEnv = {
24 | NODE_ENV: process.env.NODE_ENV,
25 | NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
26 | CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
27 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
28 | process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
29 | DB_HOST: process.env.DB_HOST,
30 | DB_USERNAME: process.env.DB_USERNAME,
31 | DB_PASSWORD: process.env.DB_PASSWORD,
32 | DB_URL: process.env.DB_URL,
33 | };
34 |
35 | // Don't touch the part below
36 | // --------------------------
37 |
38 | const merged = server.merge(client);
39 |
40 | /** @typedef {z.input} MergedInput */
41 | /** @typedef {z.infer} MergedOutput */
42 | /** @typedef {z.SafeParseReturnType} MergedSafeParseReturn */
43 |
44 | let env = /** @type {MergedOutput} */ (process.env);
45 |
46 | if (!!process.env.SKIP_ENV_VALIDATION == false) {
47 | const isServer = typeof window === "undefined";
48 |
49 | const parsed = /** @type {MergedSafeParseReturn} */ (
50 | isServer
51 | ? merged.safeParse(processEnv) // on server we can validate all env vars
52 | : client.safeParse(processEnv) // on client we can only validate the ones that are exposed
53 | );
54 |
55 | if (parsed.success === false) {
56 | console.error(
57 | "❌ Invalid environment variables:",
58 | parsed.error.flatten().fieldErrors
59 | );
60 | throw new Error("Invalid environment variables");
61 | }
62 |
63 | env = new Proxy(parsed.data, {
64 | get(target, prop) {
65 | if (typeof prop !== "string") return undefined;
66 | // Throw a descriptive error if a server-side env var is accessed on the client
67 | // Otherwise it would just be returning `undefined` and be annoying to debug
68 | if (!isServer && !prop.startsWith("NEXT_PUBLIC_"))
69 | throw new Error(
70 | process.env.NODE_ENV === "production"
71 | ? "❌ Attempted to access a server-side environment variable on the client"
72 | : `❌ Attempted to access server-side environment variable '${prop}' on the client`
73 | );
74 | return target[/** @type {keyof typeof target} */ (prop)];
75 | },
76 | });
77 | }
78 |
79 | export { env };
80 |
--------------------------------------------------------------------------------
/src/app/dashboard/create-note.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { useRouter } from "next/navigation";
5 | import { zodResolver } from "@hookform/resolvers/zod";
6 | import { Loader2 } from "lucide-react";
7 | import { useForm } from "react-hook-form";
8 | import { z } from "zod";
9 | import { Button } from "~/components/ui/button";
10 | import {
11 | Dialog,
12 | DialogContent,
13 | DialogDescription,
14 | DialogFooter,
15 | DialogHeader,
16 | DialogTitle,
17 | DialogTrigger,
18 | } from "~/components/ui/dialog";
19 | import { Input } from "~/components/ui/input";
20 | import { Label } from "~/components/ui/label";
21 | import { Textarea } from "~/components/ui/textarea";
22 | import { api } from "~/lib/api/client";
23 |
24 | export const createNoteSchema = z.object({
25 | title: z.string().min(1),
26 | text: z.string(),
27 | });
28 |
29 | type CreateNoteSchema = z.infer;
30 |
31 | export default function CreateNote() {
32 | const [open, setOpen] = useState(false);
33 |
34 | const { register, handleSubmit, reset } = useForm({
35 | resolver: zodResolver(createNoteSchema),
36 | });
37 |
38 | const router = useRouter();
39 |
40 | const { mutate: createNote, isLoading } = api.example.createNote.useMutation({
41 | onSuccess() {
42 | setOpen(false);
43 | reset();
44 | router.refresh();
45 | },
46 | });
47 |
48 | const handleCreate = (data: CreateNoteSchema) => {
49 | createNote(data);
50 | };
51 |
52 | return (
53 |
54 |
55 | Create Note
56 |
57 |
58 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/src/@trpc/next-layout/client/createTrpcNextBeta.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | /* eslint-disable @typescript-eslint/no-unsafe-call */
3 | /* eslint-disable @typescript-eslint/no-unsafe-return */
4 |
5 | "use client";
6 |
7 | import { useMemo, useState } from "react";
8 | import { QueryClientProvider } from "@tanstack/react-query";
9 | import type { CreateTRPCClientOptions } from "@trpc/client";
10 | import {
11 | createHooksInternal,
12 | createReactProxyDecoration,
13 | createReactQueryUtilsProxy,
14 | getQueryClient,
15 | type CreateReactUtilsProxy,
16 | type CreateTRPCReactOptions,
17 | type CreateTRPCReactQueryClientConfig,
18 | type DecoratedProcedureRecord,
19 | } from "@trpc/react-query/shared";
20 | import type { AnyRouter, ProtectedIntersection } from "@trpc/server";
21 | import { createFlatProxy } from "@trpc/server/shared";
22 |
23 | export type WithTRPCConfig =
24 | CreateTRPCClientOptions & CreateTRPCReactQueryClientConfig;
25 |
26 | type WithTRPCOptions =
27 | CreateTRPCReactOptions & WithTRPCConfig;
28 |
29 | /**
30 | * @internal
31 | */
32 | export interface CreateTRPCNextBase {
33 | useContext(): CreateReactUtilsProxy;
34 | Provider: ({ children }: { children: React.ReactNode }) => JSX.Element;
35 | }
36 |
37 | /**
38 | * @internal
39 | */
40 | export type CreateTRPCNext<
41 | TRouter extends AnyRouter,
42 | TFlags
43 | > = ProtectedIntersection<
44 | CreateTRPCNextBase,
45 | DecoratedProcedureRecord
46 | >;
47 |
48 | export function createTRPCNextBeta(
49 | opts: WithTRPCOptions
50 | ): CreateTRPCNext {
51 | const trpc = createHooksInternal({
52 | unstable_overrides: opts.unstable_overrides,
53 | });
54 |
55 | const TRPCProvider = ({ children }: { children: React.ReactNode }) => {
56 | const [prepassProps] = useState(() => {
57 | const queryClient = getQueryClient(opts);
58 | const trpcClient = trpc.createClient(opts);
59 | return {
60 | queryClient,
61 | trpcClient,
62 | };
63 | });
64 |
65 | const { queryClient, trpcClient } = prepassProps;
66 |
67 | return (
68 |
69 |
70 | {children}
71 |
72 |
73 | );
74 | };
75 |
76 | return createFlatProxy((key) => {
77 | if (key === "useContext") {
78 | return () => {
79 | const context = trpc.useContext();
80 | // create a stable reference of the utils context
81 | return useMemo(() => {
82 | return (createReactQueryUtilsProxy as any)(context);
83 | }, [context]);
84 | };
85 | }
86 |
87 | if (key === "Provider") {
88 | return TRPCProvider;
89 | }
90 |
91 | return createReactProxyDecoration(key, trpc);
92 | });
93 | }
94 |
--------------------------------------------------------------------------------
/src/app/dashboard/edit-note.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { useRouter } from "next/navigation";
5 | import { zodResolver } from "@hookform/resolvers/zod";
6 | import { Loader2 } from "lucide-react";
7 | import { useForm } from "react-hook-form";
8 | import { z } from "zod";
9 | import { Button } from "~/components/ui/button";
10 | import {
11 | Dialog,
12 | DialogContent,
13 | DialogDescription,
14 | DialogFooter,
15 | DialogHeader,
16 | DialogTitle,
17 | DialogTrigger,
18 | } from "~/components/ui/dialog";
19 | import { Input } from "~/components/ui/input";
20 | import { Label } from "~/components/ui/label";
21 | import { Textarea } from "~/components/ui/textarea";
22 | import { api } from "~/lib/api/client";
23 |
24 | export const editNoteSchema = z.object({
25 | id: z.number(),
26 | title: z.string().min(1).optional(),
27 | text: z.string().optional().nullable(),
28 | });
29 |
30 | type EditNoteSchema = z.infer;
31 |
32 | export default function EditNote({ id, title, text }: EditNoteSchema) {
33 | const [open, setOpen] = useState(false);
34 |
35 | const { register, handleSubmit, reset } = useForm({
36 | resolver: zodResolver(editNoteSchema),
37 | defaultValues: {
38 | id,
39 | title,
40 | text,
41 | },
42 | });
43 |
44 | const router = useRouter();
45 |
46 | const { mutate: editNote, isLoading } = api.example.editNote.useMutation({
47 | onSuccess(_, newData) {
48 | setOpen(false);
49 | reset(newData);
50 | router.refresh();
51 | },
52 | });
53 |
54 | const handleCreate = (data: EditNoteSchema) => {
55 | editNote(data);
56 | };
57 |
58 | return (
59 |
60 |
61 | Edit
62 |
63 |
64 |
65 |
66 | Edit Note
67 |
68 | {`Edit your note and click save when you're done.`}
69 |
70 |
71 |
75 |
76 | Title
77 |
78 |
79 |
80 | Note
81 |
86 |
87 |
88 |
89 |
90 | Save changes
91 | {isLoading && }
92 |
93 |
94 |
95 |
96 |
97 | );
98 | }
99 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Inter } from "next/font/google";
2 |
3 | import "~/styles/globals.css";
4 | import { siteConfig } from "~/config/site";
5 | import { cn } from "~/lib/utils";
6 |
7 | import { ClientProviders } from "./client-providers";
8 |
9 | const fontSans = Inter({
10 | weight: ["400", "500", "600", "800", "900"],
11 | subsets: ["latin"],
12 | });
13 |
14 | export const metadata = {
15 | title: {
16 | default: siteConfig.name,
17 | template: `%s | ${siteConfig.name}`,
18 | },
19 | description: siteConfig.description,
20 | keywords: [
21 | "Next.js",
22 | "App-Router",
23 | "TRPC",
24 | "Edge",
25 | "Drizzle",
26 | "PlanetScale",
27 | "T3",
28 | "Stack",
29 | "Tailwind",
30 | "shadcn/ui",
31 | "Radix",
32 | ],
33 | authors: [
34 | {
35 | name: "Oleksandr Ploskovytskyy",
36 | url: "",
37 | },
38 | ],
39 | creator: "Oleksandr Ploskovytskyy",
40 | themeColor: [
41 | { media: "(prefers-color-scheme: light)", color: "white" },
42 | { media: "(prefers-color-scheme: dark)", color: "black" },
43 | ],
44 | openGraph: {
45 | type: "website",
46 | locale: "en_US",
47 | url: siteConfig.url,
48 | title: siteConfig.name,
49 | description: siteConfig.description,
50 | siteName: siteConfig.name,
51 | },
52 | twitter: {
53 | card: "summary_large_image",
54 | title: siteConfig.name,
55 | description: siteConfig.description,
56 | creator: "@o_ploskovytskyy",
57 | },
58 | icons: {
59 | icon: "/favicon.ico",
60 | shortcut: "/favicon-16x16.png",
61 | apple: "/apple-touch-icon.png",
62 | },
63 | manifest: `${siteConfig.url}/site.webmanifest`,
64 | };
65 |
66 | type RootLayoutProps = PropsWithChildren;
67 |
68 | export default function RootLayout({ children }: RootLayoutProps) {
69 | return (
70 |
71 |
72 |
73 |
79 | {children}
80 |
106 |
107 |
108 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DialogPrimitive from "@radix-ui/react-dialog";
5 | import { X } from "lucide-react";
6 | import { cn } from "~/lib/utils";
7 |
8 | const Dialog = DialogPrimitive.Root;
9 |
10 | const DialogTrigger = DialogPrimitive.Trigger;
11 |
12 | const DialogClose = DialogPrimitive.Close;
13 |
14 | const DialogPortal = ({
15 | className,
16 | children,
17 | ...props
18 | }: DialogPrimitive.DialogPortalProps) => (
19 |
20 |
21 | {children}
22 |
23 |
24 | );
25 | DialogPortal.displayName = DialogPrimitive.Portal.displayName;
26 |
27 | const DialogOverlay = React.forwardRef<
28 | React.ElementRef,
29 | React.ComponentPropsWithoutRef
30 | >(({ className, children, ...props }, ref) => (
31 |
39 | ));
40 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
41 |
42 | const DialogContent = React.forwardRef<
43 | React.ElementRef,
44 | React.ComponentPropsWithoutRef
45 | >(({ className, children, ...props }, ref) => (
46 |
47 |
48 |
57 | {children}
58 |
59 |
60 | Close
61 |
62 |
63 |
64 | ));
65 | DialogContent.displayName = DialogPrimitive.Content.displayName;
66 |
67 | const DialogHeader = ({
68 | className,
69 | ...props
70 | }: React.HTMLAttributes) => (
71 |
78 | );
79 | DialogHeader.displayName = "DialogHeader";
80 |
81 | const DialogFooter = ({
82 | className,
83 | ...props
84 | }: React.HTMLAttributes) => (
85 |
92 | );
93 | DialogFooter.displayName = "DialogFooter";
94 |
95 | const DialogTitle = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, ...props }, ref) => (
99 |
108 | ));
109 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
110 |
111 | const DialogDescription = React.forwardRef<
112 | React.ElementRef,
113 | React.ComponentPropsWithoutRef
114 | >(({ className, ...props }, ref) => (
115 |
120 | ));
121 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
122 |
123 | export {
124 | Dialog,
125 | DialogTrigger,
126 | DialogContent,
127 | DialogHeader,
128 | DialogFooter,
129 | DialogTitle,
130 | DialogDescription,
131 | DialogClose,
132 | };
133 |
--------------------------------------------------------------------------------
/src/@trpc/next-layout/server/createTrpcNextLayout.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-return */
2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */
3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */
4 | import { QueryClient, dehydrate } from "@tanstack/query-core";
5 | import type { DehydratedState } from "@tanstack/react-query";
6 | import type {
7 | AnyProcedure,
8 | AnyQueryProcedure,
9 | AnyRouter,
10 | DataTransformer,
11 | MaybePromise,
12 | ProcedureRouterRecord,
13 | ProcedureType,
14 | inferProcedureInput,
15 | inferProcedureOutput,
16 | inferRouterContext,
17 | } from "@trpc/server";
18 | import { createRecursiveProxy } from "@trpc/server/shared";
19 |
20 | import { getRequestStorage } from "./local-storage";
21 | import "server-only";
22 |
23 | interface CreateTRPCNextLayoutOptions {
24 | router: TRouter;
25 | createContext: () => MaybePromise>;
26 | transformer?: DataTransformer;
27 | }
28 |
29 | /**
30 | * @internal
31 | */
32 | export type DecorateProcedure =
33 | TProcedure extends AnyQueryProcedure
34 | ? {
35 | fetch(
36 | input: inferProcedureInput
37 | ): Promise>;
38 | fetchInfinite(
39 | input: inferProcedureInput
40 | ): Promise>;
41 | }
42 | : never;
43 |
44 | type OmitNever = Pick<
45 | TType,
46 | {
47 | [K in keyof TType]: TType[K] extends never ? never : K;
48 | }[keyof TType]
49 | >;
50 | /**
51 | * @internal
52 | */
53 | export type DecoratedProcedureRecord<
54 | TProcedures extends ProcedureRouterRecord,
55 | TPath extends string = ""
56 | > = OmitNever<{
57 | [TKey in keyof TProcedures]: TProcedures[TKey] extends AnyRouter
58 | ? DecoratedProcedureRecord<
59 | TProcedures[TKey]["_def"]["record"],
60 | `${TPath}${TKey & string}.`
61 | >
62 | : TProcedures[TKey] extends AnyQueryProcedure
63 | ? DecorateProcedure
64 | : never;
65 | }>;
66 |
67 | type CreateTRPCNextLayout = DecoratedProcedureRecord<
68 | TRouter["_def"]["record"]
69 | > & {
70 | dehydrate(): Promise;
71 | };
72 |
73 | function getQueryKey(
74 | path: string[],
75 | input: unknown,
76 | isFetchInfinite?: boolean
77 | ) {
78 | return input === undefined
79 | ? [path, { type: isFetchInfinite ? "infinite" : "query" }] // We added { type: "infinite" | "query" }, because it is how trpc v10.0 format the new queryKeys
80 | : [
81 | path,
82 | {
83 | input: { ...input },
84 | type: isFetchInfinite ? "infinite" : "query",
85 | },
86 | ];
87 | }
88 |
89 | export function createTRPCNextLayout(
90 | opts: CreateTRPCNextLayoutOptions
91 | ): CreateTRPCNextLayout {
92 | function getState() {
93 | const requestStorage = getRequestStorage<{
94 | _trpc: {
95 | queryClient: QueryClient;
96 | context: inferRouterContext;
97 | };
98 | }>();
99 | requestStorage._trpc = requestStorage._trpc ?? {
100 | cache: Object.create(null),
101 | context: opts.createContext(),
102 | queryClient: new QueryClient({
103 | defaultOptions: {
104 | queries: {
105 | refetchOnWindowFocus: false,
106 | },
107 | },
108 | }),
109 | };
110 | return requestStorage._trpc;
111 | }
112 | const transformer = opts.transformer ?? {
113 | serialize: (v) => v,
114 | deserialize: (v) => v,
115 | };
116 |
117 | return createRecursiveProxy(async (callOpts) => {
118 | const path = [...callOpts.path];
119 | const lastPart = path.pop();
120 | const state = getState();
121 | const ctx = state.context;
122 | const { queryClient } = state;
123 |
124 | if (lastPart === "dehydrate" && path.length === 0) {
125 | if (queryClient.isFetching()) {
126 | await new Promise((resolve) => {
127 | const unsub = queryClient.getQueryCache().subscribe((event) => {
128 | if (event?.query.getObserversCount() === 0) {
129 | resolve();
130 | unsub();
131 | }
132 | });
133 | });
134 | }
135 | const dehydratedState = dehydrate(queryClient);
136 |
137 | return transformer.serialize(dehydratedState);
138 | }
139 |
140 | const fullPath = path.join(".");
141 | const procedure = opts.router._def.procedures[fullPath] as AnyProcedure;
142 |
143 | const type: ProcedureType = "query";
144 |
145 | const input = callOpts.args[0];
146 | const queryKey = getQueryKey(path, input, lastPart === "fetchInfinite");
147 |
148 | if (lastPart === "fetchInfinite") {
149 | return queryClient.fetchInfiniteQuery(queryKey, () =>
150 | procedure({
151 | rawInput: input,
152 | path: fullPath,
153 | ctx,
154 | type,
155 | })
156 | );
157 | }
158 |
159 | return queryClient.fetchQuery(queryKey, () =>
160 | procedure({
161 | rawInput: input,
162 | path: fullPath,
163 | ctx,
164 | type,
165 | })
166 | );
167 | }) as CreateTRPCNextLayout;
168 | }
169 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import FeatureCard from "~/components/feature-card";
3 | import { Code, H2, H4, Large, Lead, P } from "~/components/typography";
4 | import { buttonVariants } from "~/components/ui/button";
5 | import { siteConfig } from "~/config/site";
6 | import { api } from "~/lib/api/server";
7 | import { cn } from "~/lib/utils";
8 |
9 | import HelloFromClient from "./hello-from-client";
10 |
11 | export default async function Page() {
12 | const { greeting } = await api.example.hello.fetch({
13 | text: "Test RSC TRPC Call",
14 | });
15 |
16 | return (
17 | <>
18 |
19 | Giga Stack ✨
20 | {`The most Twitter influenced stack you've ever seen`}
21 |
34 |
35 |
36 | Features
37 |
38 | Inspired by{" "}
39 |
45 | T3 stack
46 | {" "}
47 | and{" "}
48 |
54 | Shadcn Taxonomy
55 | {" "}
56 | project, I created this example of experimental edge stack full of
57 | sweet and fancy features for you to play with. Shout out to a bunch of
58 | open source solutions on the GitHub which I combined here.
59 |
60 |
61 |
66 |
71 |
76 |
81 |
86 |
91 |
92 |
93 |
94 | Fetching
95 |
96 | In this implementation you have 2 API clients . One
97 | for RSC and one for Client components.
98 | Syntax for both are the same, with all typescript hinting features.
99 |
100 |
101 |
To make a RSC api call:
102 |
103 | {`
104 | import { api } from "~/lib/api/server";
105 |
106 | export default async function ServerComponent() {
107 | const data = await api.route.stuff.fetch();
108 | }
109 | `}
110 |
111 | Example (watch the code):
112 |
113 | {greeting}
114 |
115 |
116 |
117 |
To make a Client api call:
118 |
119 | {`
120 | "use client";
121 |
122 | import { api } from "~/lib/api/client";
123 |
124 | export default function ClientComponent() {
125 | const { data, isLoading } = await api.route.stuff.useQuery();
126 | }
127 | `}
128 |
129 | Example (watch the code):
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
Mutation:
138 |
139 | The idea is simple - mutate on client and refresh the route on
140 | success
141 |
142 |
143 | {`
144 | const router = useRouter();
145 |
146 | const { mutate, isLoading } = api.route.stuff.useMutation({
147 | onSuccess() {
148 | router.refresh();
149 | },
150 | });
151 | `}
152 |
153 |
154 |
155 | Play with demo dashboard or
156 |
157 | see project code on GitHub
158 |
159 |
172 |
173 | >
174 | );
175 | }
176 |
177 | export const runtime = "experimental-edge";
178 | export const revalidate = 0;
179 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
1 | h 6 ( � 00 h&