init commit
This commit is contained in:
1
apps/nextjs/.cache/.prettiercache
Normal file
1
apps/nextjs/.cache/.prettiercache
Normal file
File diff suppressed because one or more lines are too long
15
apps/nextjs/eslint.config.ts
Normal file
15
apps/nextjs/eslint.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "eslint/config";
|
||||
|
||||
import { baseConfig, restrictEnvAccess } from "@acme/eslint-config/base";
|
||||
import { nextjsConfig } from "@acme/eslint-config/nextjs";
|
||||
import { reactConfig } from "@acme/eslint-config/react";
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
ignores: [".next/**"],
|
||||
},
|
||||
baseConfig,
|
||||
reactConfig,
|
||||
nextjsConfig,
|
||||
restrictEnvAccess,
|
||||
);
|
||||
23
apps/nextjs/next.config.js
Normal file
23
apps/nextjs/next.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
|
||||
// Import env files to validate at build time. Use jiti so we can load .ts files in here.
|
||||
await jiti.import("./src/env");
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
/** Enables hot reloading for local packages without a build step */
|
||||
transpilePackages: [
|
||||
"@acme/api",
|
||||
"@acme/auth",
|
||||
"@acme/db",
|
||||
"@acme/ui",
|
||||
"@acme/validators",
|
||||
],
|
||||
|
||||
/** We already do linting and typechecking as separate tasks in CI */
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
};
|
||||
|
||||
export default config;
|
||||
43
apps/nextjs/package.json
Normal file
43
apps/nextjs/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@acme/nextjs",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||
"start": "pnpm with-env next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@acme/backend": "workspace:*",
|
||||
"@acme/ui": "workspace:*",
|
||||
"@convex-dev/auth": "workspace:*",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"convex": "workspace:*",
|
||||
"next": "^16.0.0",
|
||||
"react": "catalog:react19",
|
||||
"react-dom": "catalog:react19",
|
||||
"superjson": "2.2.3",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@acme/eslint-config": "workspace:*",
|
||||
"@acme/prettier-config": "workspace:*",
|
||||
"@acme/tailwind-config": "workspace:*",
|
||||
"@acme/tsconfig": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:react19",
|
||||
"@types/react-dom": "catalog:react19",
|
||||
"eslint": "catalog:",
|
||||
"jiti": "^2.5.1",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"prettier": "@acme/prettier-config"
|
||||
}
|
||||
1
apps/nextjs/postcss.config.js
Normal file
1
apps/nextjs/postcss.config.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@acme/tailwind-config/postcss-config";
|
||||
BIN
apps/nextjs/public/favicon.ico
Normal file
BIN
apps/nextjs/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
13
apps/nextjs/public/t3-icon.svg
Normal file
13
apps/nextjs/public/t3-icon.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg width="258" height="198" viewBox="0 0 258 198" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_12)">
|
||||
<path d="M165.269 24.0976L188.481 -0.000411987H0V24.0976H165.269Z" fill="black"/>
|
||||
<path d="M163.515 95.3516L253.556 2.71059H220.74L145.151 79.7886L163.515 95.3516Z" fill="black"/>
|
||||
<path d="M233.192 130.446C233.192 154.103 214.014 173.282 190.357 173.282C171.249 173.282 155.047 160.766 149.534 143.467L146.159 132.876L126.863 152.171L128.626 156.364C138.749 180.449 162.568 197.382 190.357 197.382C227.325 197.382 257.293 167.414 257.293 130.446C257.293 105.965 243.933 84.7676 224.49 73.1186L219.929 70.3856L202.261 88.2806L210.322 92.5356C223.937 99.7236 233.192 114.009 233.192 130.446Z" fill="black"/>
|
||||
<path d="M87.797 191.697V44.6736H63.699V191.697H87.797Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_12">
|
||||
<rect width="258" height="198" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 923 B |
58
apps/nextjs/src/app/_components/auth-showcase.tsx
Normal file
58
apps/nextjs/src/app/_components/auth-showcase.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Button } from "@acme/ui/button";
|
||||
|
||||
import { auth, getSession } from "~/auth/server";
|
||||
|
||||
export async function AuthShowcase() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<form>
|
||||
<Button
|
||||
size="lg"
|
||||
formAction={async () => {
|
||||
"use server";
|
||||
const res = await auth.api.signInSocial({
|
||||
body: {
|
||||
provider: "discord",
|
||||
callbackURL: "/",
|
||||
},
|
||||
});
|
||||
if (!res.url) {
|
||||
throw new Error("No URL returned from signInSocial");
|
||||
}
|
||||
redirect(res.url);
|
||||
}}
|
||||
>
|
||||
Sign in with Discord
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<p className="text-center text-2xl">
|
||||
<span>Logged in as {session.user.name}</span>
|
||||
</p>
|
||||
|
||||
<form>
|
||||
<Button
|
||||
size="lg"
|
||||
formAction={async () => {
|
||||
"use server";
|
||||
await auth.api.signOut({
|
||||
headers: await headers(),
|
||||
});
|
||||
redirect("/");
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
apps/nextjs/src/app/_components/posts.tsx
Normal file
210
apps/nextjs/src/app/_components/posts.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
useSuspenseQuery,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type { RouterOutputs } from "@acme/api";
|
||||
import { CreatePostSchema } from "@acme/db/schema";
|
||||
import { cn } from "@acme/ui";
|
||||
import { Button } from "@acme/ui/button";
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@acme/ui/field";
|
||||
import { Input } from "@acme/ui/input";
|
||||
import { toast } from "@acme/ui/toast";
|
||||
|
||||
import { useTRPC } from "~/trpc/react";
|
||||
|
||||
export function CreatePostForm() {
|
||||
const trpc = useTRPC();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const createPost = useMutation(
|
||||
trpc.post.create.mutationOptions({
|
||||
onSuccess: async () => {
|
||||
form.reset();
|
||||
await queryClient.invalidateQueries(trpc.post.pathFilter());
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err.data?.code === "UNAUTHORIZED"
|
||||
? "You must be logged in to post"
|
||||
: "Failed to create post",
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
content: "",
|
||||
title: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: CreatePostSchema,
|
||||
},
|
||||
onSubmit: (data) => createPost.mutate(data.value),
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="w-full max-w-2xl"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<form.Field
|
||||
name="title"
|
||||
children={(field) => {
|
||||
const isInvalid =
|
||||
field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
|
||||
</FieldContent>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Title"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<form.Field
|
||||
name="content"
|
||||
children={(field) => {
|
||||
const isInvalid =
|
||||
field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={field.name}>Content</FieldLabel>
|
||||
</FieldContent>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Content"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<Button type="submit">Create</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostList() {
|
||||
const trpc = useTRPC();
|
||||
const { data: posts } = useSuspenseQuery(trpc.post.all.queryOptions());
|
||||
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<div className="relative flex w-full flex-col gap-4">
|
||||
<PostCardSkeleton pulse={false} />
|
||||
<PostCardSkeleton pulse={false} />
|
||||
<PostCardSkeleton pulse={false} />
|
||||
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/10">
|
||||
<p className="text-2xl font-bold text-white">No posts yet</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{posts.map((p) => {
|
||||
return <PostCard key={p.id} post={p} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostCard(props: {
|
||||
post: RouterOutputs["post"]["all"][number];
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const deletePost = useMutation(
|
||||
trpc.post.delete.mutationOptions({
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(trpc.post.pathFilter());
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err.data?.code === "UNAUTHORIZED"
|
||||
? "You must be logged in to delete a post"
|
||||
: "Failed to delete post",
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-muted flex flex-row rounded-lg p-4">
|
||||
<div className="grow">
|
||||
<h2 className="text-primary text-2xl font-bold">{props.post.title}</h2>
|
||||
<p className="mt-2 text-sm">{props.post.content}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-primary cursor-pointer text-sm font-bold uppercase hover:bg-transparent hover:text-white"
|
||||
onClick={() => deletePost.mutate(props.post.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostCardSkeleton(props: { pulse?: boolean }) {
|
||||
const { pulse = true } = props;
|
||||
return (
|
||||
<div className="bg-muted flex flex-row rounded-lg p-4">
|
||||
<div className="grow">
|
||||
<h2
|
||||
className={cn(
|
||||
"bg-primary w-1/4 rounded-sm text-2xl font-bold",
|
||||
pulse && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
|
||||
</h2>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 w-1/3 rounded-sm bg-current text-sm",
|
||||
pulse && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
apps/nextjs/src/app/api/auth/[...all]/route.ts
Normal file
4
apps/nextjs/src/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "~/auth/server";
|
||||
|
||||
export const GET = auth.handler;
|
||||
export const POST = auth.handler;
|
||||
46
apps/nextjs/src/app/api/trpc/[trpc]/route.ts
Normal file
46
apps/nextjs/src/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
|
||||
import { appRouter, createTRPCContext } from "@acme/api";
|
||||
|
||||
import { auth } from "~/auth/server";
|
||||
|
||||
/**
|
||||
* Configure basic CORS headers
|
||||
* You should extend this to match your needs
|
||||
*/
|
||||
const setCorsHeaders = (res: Response) => {
|
||||
res.headers.set("Access-Control-Allow-Origin", "*");
|
||||
res.headers.set("Access-Control-Request-Method", "*");
|
||||
res.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST");
|
||||
res.headers.set("Access-Control-Allow-Headers", "*");
|
||||
};
|
||||
|
||||
export const OPTIONS = () => {
|
||||
const response = new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
setCorsHeaders(response);
|
||||
return response;
|
||||
};
|
||||
|
||||
const handler = async (req: NextRequest) => {
|
||||
const response = await fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
router: appRouter,
|
||||
req,
|
||||
createContext: () =>
|
||||
createTRPCContext({
|
||||
auth: auth,
|
||||
headers: req.headers,
|
||||
}),
|
||||
onError({ error, path }) {
|
||||
console.error(`>>> tRPC Error on '${path}'`, error);
|
||||
},
|
||||
});
|
||||
|
||||
setCorsHeaders(response);
|
||||
return response;
|
||||
};
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
70
apps/nextjs/src/app/layout.tsx
Normal file
70
apps/nextjs/src/app/layout.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
|
||||
import { cn } from "@acme/ui";
|
||||
import { ThemeProvider, ThemeToggle } from "@acme/ui/theme";
|
||||
import { Toaster } from "@acme/ui/toast";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
|
||||
import "~/app/styles.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(
|
||||
env.VERCEL_ENV === "production"
|
||||
? "https://turbo.t3.gg"
|
||||
: "http://localhost:3000",
|
||||
),
|
||||
title: "Create T3 Turbo",
|
||||
description: "Simple monorepo with shared backend for web & mobile apps",
|
||||
openGraph: {
|
||||
title: "Create T3 Turbo",
|
||||
description: "Simple monorepo with shared backend for web & mobile apps",
|
||||
url: "https://create-t3-turbo.vercel.app",
|
||||
siteName: "Create T3 Turbo",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: "@jullerino",
|
||||
creator: "@jullerino",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "white" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
||||
],
|
||||
};
|
||||
|
||||
const geistSans = Geist({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-sans",
|
||||
});
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-mono",
|
||||
});
|
||||
|
||||
export default function RootLayout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
"bg-background text-foreground min-h-screen font-sans antialiased",
|
||||
geistSans.variable,
|
||||
geistMono.variable,
|
||||
)}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<TRPCReactProvider>{props.children}</TRPCReactProvider>
|
||||
<div className="absolute right-4 bottom-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
41
apps/nextjs/src/app/page.tsx
Normal file
41
apps/nextjs/src/app/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { HydrateClient, prefetch, trpc } from "~/trpc/server";
|
||||
import { AuthShowcase } from "./_components/auth-showcase";
|
||||
import {
|
||||
CreatePostForm,
|
||||
PostCardSkeleton,
|
||||
PostList,
|
||||
} from "./_components/posts";
|
||||
|
||||
export default function HomePage() {
|
||||
prefetch(trpc.post.all.queryOptions());
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<main className="container h-screen py-16">
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
|
||||
Create <span className="text-primary">T3</span> Turbo
|
||||
</h1>
|
||||
<AuthShowcase />
|
||||
|
||||
<CreatePostForm />
|
||||
<div className="w-full max-w-2xl overflow-y-scroll">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
<PostCardSkeleton />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PostList />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
29
apps/nextjs/src/app/styles.css
Normal file
29
apps/nextjs/src/app/styles.css
Normal file
@@ -0,0 +1,29 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@acme/tailwind-config/theme";
|
||||
|
||||
@source "../../../../packages/ui/src/*.{ts,tsx}";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
@custom-variant light (&:where(.light, .light *));
|
||||
@custom-variant auto (&:where(.auto, .auto *));
|
||||
|
||||
@utility container {
|
||||
margin-inline: auto;
|
||||
padding-inline: 2rem;
|
||||
@media (width >= --theme(--breakpoint-sm)) {
|
||||
max-width: none;
|
||||
}
|
||||
@media (width >= 1400px) {
|
||||
max-width: 1400px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
}
|
||||
3
apps/nextjs/src/auth/client.ts
Normal file
3
apps/nextjs/src/auth/client.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient();
|
||||
29
apps/nextjs/src/auth/server.ts
Normal file
29
apps/nextjs/src/auth/server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import "server-only";
|
||||
|
||||
import { cache } from "react";
|
||||
import { headers } from "next/headers";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
|
||||
import { initAuth } from "@acme/auth";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
const baseUrl =
|
||||
env.VERCEL_ENV === "production"
|
||||
? `https://${env.VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: env.VERCEL_ENV === "preview"
|
||||
? `https://${env.VERCEL_URL}`
|
||||
: "http://localhost:3000";
|
||||
|
||||
export const auth = initAuth({
|
||||
baseUrl,
|
||||
productionUrl: `https://${env.VERCEL_PROJECT_PRODUCTION_URL ?? "turbo.t3.gg"}`,
|
||||
secret: env.AUTH_SECRET,
|
||||
discordClientId: env.AUTH_DISCORD_ID,
|
||||
discordClientSecret: env.AUTH_DISCORD_SECRET,
|
||||
extraPlugins: [nextCookies()],
|
||||
});
|
||||
|
||||
export const getSession = cache(async () =>
|
||||
auth.api.getSession({ headers: await headers() }),
|
||||
);
|
||||
39
apps/nextjs/src/env.ts
Normal file
39
apps/nextjs/src/env.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { vercel } from "@t3-oss/env-nextjs/presets-zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { authEnv } from "@acme/auth/env";
|
||||
|
||||
export const env = createEnv({
|
||||
extends: [authEnv(), vercel()],
|
||||
shared: {
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
},
|
||||
/**
|
||||
* Specify your server-side environment variables schema here.
|
||||
* This way you can ensure the app isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
POSTGRES_URL: z.url(),
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your client-side environment variables schema here.
|
||||
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||
},
|
||||
/**
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
*/
|
||||
experimental__runtimeEnv: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
skipValidation:
|
||||
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
||||
});
|
||||
33
apps/nextjs/src/trpc/query-client.ts
Normal file
33
apps/nextjs/src/trpc/query-client.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
defaultShouldDehydrateQuery,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
export const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// With SSR, we usually want to set some default staleTime
|
||||
// above 0 to avoid refetching immediately on the client
|
||||
staleTime: 30 * 1000,
|
||||
},
|
||||
dehydrate: {
|
||||
serializeData: SuperJSON.serialize,
|
||||
shouldDehydrateQuery: (query) =>
|
||||
defaultShouldDehydrateQuery(query) ||
|
||||
query.state.status === "pending",
|
||||
shouldRedactErrors: () => {
|
||||
// We should not catch Next.js server errors
|
||||
// as that's how Next.js detects dynamic pages
|
||||
// so we cannot redact them.
|
||||
// Next.js also automatically redacts errors for us
|
||||
// with better digests.
|
||||
return false;
|
||||
},
|
||||
},
|
||||
hydrate: {
|
||||
deserializeData: SuperJSON.deserialize,
|
||||
},
|
||||
},
|
||||
});
|
||||
70
apps/nextjs/src/trpc/react.tsx
Normal file
70
apps/nextjs/src/trpc/react.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
createTRPCClient,
|
||||
httpBatchStreamLink,
|
||||
loggerLink,
|
||||
} from "@trpc/client";
|
||||
import { createTRPCContext } from "@trpc/tanstack-react-query";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { AppRouter } from "@acme/api";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { createQueryClient } from "./query-client";
|
||||
|
||||
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
||||
const getQueryClient = () => {
|
||||
if (typeof window === "undefined") {
|
||||
// Server: always make a new query client
|
||||
return createQueryClient();
|
||||
} else {
|
||||
// Browser: use singleton pattern to keep the same query client
|
||||
return (clientQueryClientSingleton ??= createQueryClient());
|
||||
}
|
||||
};
|
||||
|
||||
export const { useTRPC, TRPCProvider } = createTRPCContext<AppRouter>();
|
||||
|
||||
export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
const [trpcClient] = useState(() =>
|
||||
createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (op) =>
|
||||
env.NODE_ENV === "development" ||
|
||||
(op.direction === "down" && op.result instanceof Error),
|
||||
}),
|
||||
httpBatchStreamLink({
|
||||
transformer: SuperJSON,
|
||||
url: getBaseUrl() + "/api/trpc",
|
||||
headers() {
|
||||
const headers = new Headers();
|
||||
headers.set("x-trpc-source", "nextjs-react");
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
|
||||
{props.children}
|
||||
</TRPCProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const getBaseUrl = () => {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
if (env.VERCEL_URL) return `https://${env.VERCEL_URL}`;
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
};
|
||||
54
apps/nextjs/src/trpc/server.tsx
Normal file
54
apps/nextjs/src/trpc/server.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { TRPCQueryOptions } from "@trpc/tanstack-react-query";
|
||||
import { cache } from "react";
|
||||
import { headers } from "next/headers";
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||
|
||||
import type { AppRouter } from "@acme/api";
|
||||
import { appRouter, createTRPCContext } from "@acme/api";
|
||||
|
||||
import { auth } from "~/auth/server";
|
||||
import { createQueryClient } from "./query-client";
|
||||
|
||||
/**
|
||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||
* handling a tRPC call from a React Server Component.
|
||||
*/
|
||||
const createContext = cache(async () => {
|
||||
const heads = new Headers(await headers());
|
||||
heads.set("x-trpc-source", "rsc");
|
||||
|
||||
return createTRPCContext({
|
||||
headers: heads,
|
||||
auth,
|
||||
});
|
||||
});
|
||||
|
||||
const getQueryClient = cache(createQueryClient);
|
||||
|
||||
export const trpc = createTRPCOptionsProxy<AppRouter>({
|
||||
router: appRouter,
|
||||
ctx: createContext,
|
||||
queryClient: getQueryClient,
|
||||
});
|
||||
|
||||
export function HydrateClient(props: { children: React.ReactNode }) {
|
||||
const queryClient = getQueryClient();
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
{props.children}
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(
|
||||
queryOptions: T,
|
||||
) {
|
||||
const queryClient = getQueryClient();
|
||||
if (queryOptions.queryKey[1]?.type === "infinite") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
|
||||
void queryClient.prefetchInfiniteQuery(queryOptions as any);
|
||||
} else {
|
||||
void queryClient.prefetchQuery(queryOptions);
|
||||
}
|
||||
}
|
||||
13
apps/nextjs/tsconfig.json
Normal file
13
apps/nextjs/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@acme/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "dom", "dom.iterable"],
|
||||
"jsx": "preserve",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"plugins": [{ "name": "next" }]
|
||||
},
|
||||
"include": [".", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
13
apps/nextjs/turbo.json
Normal file
13
apps/nextjs/turbo.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://turborepo.com/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "next-env.d.ts"]
|
||||
},
|
||||
"dev": {
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user