boilerplate

This commit is contained in:
JonLuca De Caro
2026-03-03 18:43:35 -08:00
parent 66dace3f9b
commit 5ff5ef65c3
48 changed files with 4737 additions and 0 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/web_boilerplate"
BETTER_AUTH_SECRET="replace-with-a-32-character-secret"
BETTER_AUTH_URL="http://localhost:3456"
NODE_ENV="development"

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
node_modules/
.next/
out/
dist/
.cache/
.parcel-cache/
.env
.env.local
.env.development.local
.env.production.local
.env.test.local
generated/prisma
*.tsbuildinfo
*.tgz
.idea/
.vscode/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v24

View File

@@ -0,0 +1,73 @@
# Web Boilerplate
Generic Next.js Pages Router boilerplate built from the shared stack used in `resonate-gallery`.
## Stack
- Next.js 16 Pages Router
- React 19
- Tailwind CSS 4
- tRPC 11
- Prisma 7 with PostgreSQL
- Better Auth email/password authentication
- Biome
## Models
- `User`
- `Session`
- `Account`
- `Verification`
- `Post`
- `Comment`
## Routes
- Public: `/`, `/posts`, `/posts/[postId]`, `/sign-in`, `/sign-up`
- Protected: `/dashboard`, `/account`
## Bootstrap
1. Install dependencies.
```bash
pnpm install
```
2. Copy the example environment file and set a real PostgreSQL connection string plus Better Auth secret.
```bash
cp .env.example .env
```
3. Apply the committed migration or generate a local dev database from scratch.
```bash
pnpm db:migrate
```
Or for local development:
```bash
pnpm db:generate
```
4. Regenerate the Prisma client if needed.
```bash
pnpm exec prisma generate
```
5. Run the app.
```bash
pnpm dev
```
## Validation
```bash
pnpm typecheck
pnpm check
pnpm build
```

79
biome.jsonc Normal file
View File

@@ -0,0 +1,79 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"root": true,
"vcs": {
"enabled": true,
"useIgnoreFile": true,
"clientKind": "git"
},
"files": {
"includes": ["**", "!!node_modules", "!!generated"]
},
"assist": {
"enabled": true,
"actions": {
"recommended": true,
"source": {
"recommended": true,
"organizeImports": "on",
"useSortedAttributes": "on"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 140
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"nursery": {
"useSortedClasses": {
"level": "warn",
"fix": "safe",
"options": {
"attributes": ["className", "class"],
"functions": ["clsx", "twMerge", "cn"]
}
}
}
}
},
"html": {
"formatter": {
"enabled": true
}
},
"javascript": {
"assist": {
"enabled": true
},
"formatter": {
"enabled": true,
"arrowParentheses": "always",
"semicolons": "always",
"trailingCommas": "all"
},
"linter": {
"enabled": true
}
},
"css": {
"assist": {
"enabled": true
},
"formatter": {
"enabled": true
},
"linter": {
"enabled": true
},
"parser": {
"cssModules": true,
"tailwindDirectives": true
}
}
}

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

19
next.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import "./src/env.js";
import { fileURLToPath } from "node:url";
import type { NextConfig } from "next";
const dirname = fileURLToPath(new URL(".", import.meta.url));
const config = {
reactStrictMode: true,
i18n: {
locales: ["en"],
defaultLocale: "en",
},
turbopack: {
root: dirname,
},
reactCompiler: true,
} as NextConfig;
export default config;

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "web-boilerplate",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "SKIP_ENV_VALIDATION=1 next build",
"check": "biome check .",
"check:unsafe": "biome check --write --unsafe .",
"check:write": "biome check --write .",
"db:generate": "prisma migrate dev",
"db:migrate": "prisma migrate deploy",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"dev": "next dev --turbo -p 3456",
"postinstall": "prisma generate",
"preview": "next build && next start",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.21",
"@trpc/client": "^11.10.0",
"@trpc/next": "^11.10.0",
"@trpc/react-query": "^11.10.0",
"@trpc/server": "^11.10.0",
"babel-plugin-react-compiler": "^1.0.0",
"better-auth": "^1.5.2",
"next": "^16.1.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"superjson": "^2.2.6",
"zod": "^4.3.6"
},
"devDependencies": {
"@biomejs/biome": "^2.4.4",
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^25.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"prisma": "^7.4.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3"
},
"packageManager": "pnpm@10.30.3"
}

2573
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
onlyBuiltDependencies:
- "@prisma/engines"
- prisma
- sharp
nodeLinker: hoisted

5
postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

9
prisma.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: {
url: process.env.DATABASE_URL,
},
});

View File

@@ -0,0 +1,117 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3),
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Post" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"body" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex
CREATE INDEX "Post_authorId_idx" ON "Post"("authorId");
-- CreateIndex
CREATE INDEX "Post_createdAt_idx" ON "Post"("createdAt");
-- CreateIndex
CREATE INDEX "Comment_postId_idx" ON "Comment"("postId");
-- CreateIndex
CREATE INDEX "Comment_authorId_idx" ON "Comment"("authorId");
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1 @@
provider = "postgresql"

102
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,102 @@
generator client {
provider = "prisma-client"
output = "../generated/prisma"
previewFeatures = ["fullTextSearchPostgres", "relationJoins", "nativeDistinct", "views"]
moduleFormat = "esm"
generatedFileExtension = "ts"
importFileExtension = "ts"
engineType = "client"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id
name String
email String @unique
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
sessions Session[]
accounts Account[]
posts Post[]
comments Comment[]
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String @unique
createdAt DateTime
updatedAt DateTime
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}
model Post {
id String @id @default(cuid())
title String
body String
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([createdAt])
}
model Comment {
id String @id @default(cuid())
body String
postId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([postId])
@@index([authorId])
}

159
src/components/AuthForm.tsx Normal file
View File

@@ -0,0 +1,159 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { startTransition, useState } from "react";
import { signIn, signUp } from "~/utils/auth-client";
type AuthFormProps = {
mode: "sign-in" | "sign-up";
nextPath: string;
};
const inputClassName =
"w-full rounded-[18px] border border-white/12 bg-[#0b1320] px-4 py-3 text-sm text-white outline-none transition placeholder:text-[#6c7790] focus:border-[#9db6ff] focus:bg-[#101928]";
export const AuthForm = ({ mode, nextPath }: AuthFormProps) => {
const router = useRouter();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const [formState, setFormState] = useState({
email: "",
name: "",
password: "",
});
const isSignUp = mode === "sign-up";
const alternateHref = `${isSignUp ? "/sign-in" : "/sign-up"}?next=${encodeURIComponent(nextPath)}`;
const handleSubmit = async () => {
setIsPending(true);
setErrorMessage(null);
try {
const result = isSignUp
? await signUp.email({
name: formState.name,
email: formState.email,
password: formState.password,
callbackURL: nextPath,
})
: await signIn.email({
email: formState.email,
password: formState.password,
callbackURL: nextPath,
});
if (result.error) {
setErrorMessage(result.error.message ?? "Unable to continue with authentication.");
return;
}
startTransition(() => {
void router.replace(nextPath);
});
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "Unable to continue with authentication.");
} finally {
setIsPending(false);
}
};
return (
<div className="mx-auto max-w-xl rounded-[30px] border border-white/12 bg-[#0a1220]/90 p-6 shadow-[0_24px_90px_rgba(3,7,18,0.35)] md:p-8">
<div className="flex flex-col gap-2">
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.28em]">
{isSignUp ? "Create an account" : "Welcome back"}
</p>
<h2 className="text-2xl tracking-[-0.04em]">{isSignUp ? "Set up email and password auth" : "Sign in to the protected area"}</h2>
<p className="text-[#c5d0ea] text-sm leading-6">
{isSignUp
? "This boilerplate only enables Better Auth email and password flows."
: "Use the same credentials you created with Better Auth email and password."}
</p>
</div>
<form
className="mt-8 space-y-4"
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
>
{isSignUp ? (
<label className="block space-y-2">
<span className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.24em]">Name</span>
<input
className={inputClassName}
onChange={(event) =>
setFormState((current) => ({
...current,
name: event.target.value,
}))
}
placeholder="Jane Example"
required
type="text"
value={formState.name}
/>
</label>
) : null}
<label className="block space-y-2">
<span className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.24em]">Email</span>
<input
autoComplete="email"
className={inputClassName}
onChange={(event) =>
setFormState((current) => ({
...current,
email: event.target.value,
}))
}
placeholder="jane@example.com"
required
type="email"
value={formState.email}
/>
</label>
<label className="block space-y-2">
<span className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.24em]">Password</span>
<input
autoComplete={isSignUp ? "new-password" : "current-password"}
className={inputClassName}
minLength={8}
onChange={(event) =>
setFormState((current) => ({
...current,
password: event.target.value,
}))
}
placeholder="At least 8 characters"
required
type="password"
value={formState.password}
/>
</label>
{errorMessage ? (
<div className="rounded-[18px] border border-[#73464a] bg-[#241114] px-4 py-3 text-[#ffc9d0] text-sm">{errorMessage}</div>
) : null}
<button
className="w-full rounded-[18px] bg-[#9db6ff] px-4 py-3 font-mono text-[#09111d] text-[11px] uppercase tracking-[0.24em] transition hover:bg-[#b8cbff] disabled:cursor-not-allowed disabled:bg-[#556888] disabled:text-[#d4def8]"
disabled={isPending}
type="submit"
>
{isPending ? "Working..." : isSignUp ? "Create account" : "Sign in"}
</button>
</form>
<p className="mt-5 text-[#b6c0da] text-sm">
{isSignUp ? "Already have an account?" : "Need an account?"}{" "}
<Link className="text-[#9db6ff] underline underline-offset-4" href={alternateHref}>
{isSignUp ? "Sign in here" : "Create one here"}
</Link>
</p>
</div>
);
};

View File

@@ -0,0 +1,69 @@
import { useState } from "react";
import { api } from "~/utils/api";
type CommentFormProps = {
postId: string;
};
const inputClassName =
"w-full rounded-[18px] border border-white/12 bg-[#0b1320] px-4 py-3 text-sm text-white outline-none transition placeholder:text-[#6c7790] focus:border-[#9db6ff] focus:bg-[#101928]";
export const CommentForm = ({ postId }: CommentFormProps) => {
const utils = api.useUtils();
const [body, setBody] = useState("");
const [message, setMessage] = useState<string | null>(null);
const createComment = api.comment.create.useMutation({
onSuccess: async () => {
setBody("");
setMessage("Comment created.");
await utils.post.byId.invalidate({ postId });
},
onError: (error) => {
setMessage(error.message);
},
});
return (
<section className="rounded-[26px] border border-white/12 bg-[#0a1220]/85 p-5">
<div className="flex flex-col gap-2">
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.24em]">Protected write route</p>
<h2 className="text-xl tracking-[-0.04em]">Add a comment</h2>
</div>
<form
className="mt-5 space-y-4"
onSubmit={(event) => {
event.preventDefault();
setMessage(null);
createComment.mutate({
body,
postId,
});
}}
>
<textarea
className={`${inputClassName} min-h-32 resize-y`}
maxLength={4000}
onChange={(event) => setBody(event.target.value)}
placeholder="Leave a short note on the post."
required
value={body}
/>
{message ? (
<div className="rounded-[18px] border border-white/10 bg-white/[0.04] px-4 py-3 text-[#dbe4ff] text-sm">{message}</div>
) : null}
<button
className="rounded-[18px] bg-[#9db6ff] px-4 py-3 font-mono text-[#09111d] text-[11px] uppercase tracking-[0.24em] transition hover:bg-[#b8cbff] disabled:cursor-not-allowed disabled:bg-[#556888] disabled:text-[#d4def8]"
disabled={createComment.isPending}
type="submit"
>
{createComment.isPending ? "Posting..." : "Post comment"}
</button>
</form>
</section>
);
};

View File

@@ -0,0 +1,105 @@
import Link from "next/link";
import { useRouter } from "next/router";
import type { ReactNode } from "react";
import { useSession } from "~/utils/auth-client";
type PageShellProps = {
actions?: ReactNode;
children: ReactNode;
eyebrow?: string;
intro: string;
title: string;
};
const linkClassName =
"rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.22em] transition hover:border-white/40 hover:bg-white/8";
const isActivePath = (pathname: string, href: string) => {
if (href === "/") {
return pathname === href;
}
return pathname === href || pathname.startsWith(`${href}/`);
};
export const PageShell = ({ actions, children, eyebrow, intro, title }: PageShellProps) => {
const router = useRouter();
const { data: session } = useSession();
const primaryLinks = [
{ href: "/", label: "Overview" },
{ href: "/posts", label: "Posts" },
];
const secondaryLinks = session
? [
{ href: "/dashboard", label: "Dashboard" },
{ href: "/account", label: "Account" },
]
: [
{ href: "/sign-in", label: "Sign in" },
{ href: "/sign-up", label: "Sign up" },
];
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(93,127,255,0.24),transparent_38%),linear-gradient(180deg,#08111b_0%,#0d1522_45%,#060b12_100%)] text-[#f3f6ff]">
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col px-5 py-6 sm:px-8 lg:px-10">
<header className="rounded-[28px] border border-white/12 bg-white/[0.04] px-5 py-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)] backdrop-blur md:px-7">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-2xl">
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.32em]">{eyebrow ?? "Generic Boilerplate"}</p>
<h1 className="mt-3 text-3xl tracking-[-0.04em] sm:text-4xl lg:text-5xl">{title}</h1>
<p className="mt-4 max-w-2xl text-[#c5d0ea] text-[15px] leading-7 sm:text-base">{intro}</p>
</div>
<div className="flex flex-col gap-3 lg:items-end">
<div className="rounded-full border border-white/10 bg-[#101b2a] px-4 py-2 text-[#d6ddf2] text-sm">
{session ? `Signed in as ${session.user.email}` : "Browsing public routes"}
</div>
{actions ? <div className="flex flex-wrap gap-3">{actions}</div> : null}
</div>
</div>
<div className="flex flex-col gap-3 border-white/10 border-t pt-4 lg:flex-row lg:items-center lg:justify-between">
<nav className="flex flex-wrap gap-3">
{primaryLinks.map((link) => (
<Link
className={`${linkClassName} ${
isActivePath(router.pathname, link.href)
? "border-[#9db6ff] bg-[#122138] text-white"
: "border-white/12 text-[#d7e0f4]"
}`}
href={link.href}
key={link.href}
>
{link.label}
</Link>
))}
</nav>
<nav className="flex flex-wrap gap-3">
{secondaryLinks.map((link) => (
<Link
className={`${linkClassName} ${
isActivePath(router.pathname, link.href)
? "border-[#9db6ff] bg-[#122138] text-white"
: "border-white/12 text-[#d7e0f4]"
}`}
href={link.href}
key={link.href}
>
{link.label}
</Link>
))}
</nav>
</div>
</div>
</header>
<main className="flex-1 py-8">{children}</main>
</div>
</div>
);
};

View File

@@ -0,0 +1,33 @@
import Link from "next/link";
import type { RouterOutputs } from "~/utils/api";
import { formatDateTime, truncateText } from "~/utils/formatting";
type PostCardProps = {
post: RouterOutputs["post"]["list"][number];
};
export const PostCard = ({ post }: PostCardProps) => {
return (
<article className="flex h-full flex-col rounded-[26px] border border-white/12 bg-white/[0.05] p-5 shadow-[0_18px_70px_rgba(2,6,23,0.25)]">
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-mono text-[#8fa6dd] text-[11px] uppercase tracking-[0.24em]">{post.author.name}</p>
<h3 className="mt-3 text-xl tracking-[-0.04em]">{post.title}</h3>
</div>
<span className="rounded-full border border-white/10 px-3 py-1 font-mono text-[#b5c0da] text-[10px] uppercase tracking-[0.22em]">
{post._count.comments} comments
</span>
</div>
<p className="mt-4 flex-1 text-[#cbd4eb] text-sm leading-7">{truncateText(post.body, 220)}</p>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 border-white/10 border-t pt-4 text-[#99a5c4] text-sm">
<span>{formatDateTime(post.createdAt)}</span>
<Link className="font-mono text-[#9db6ff] text-[11px] uppercase tracking-[0.24em]" href={`/posts/${post.id}`}>
Read post
</Link>
</div>
</article>
);
};

View File

@@ -0,0 +1,97 @@
import { useState } from "react";
import { api } from "~/utils/api";
const inputClassName =
"w-full rounded-[18px] border border-white/12 bg-[#0b1320] px-4 py-3 text-sm text-white outline-none transition placeholder:text-[#6c7790] focus:border-[#9db6ff] focus:bg-[#101928]";
export const PostForm = () => {
const utils = api.useUtils();
const [message, setMessage] = useState<string | null>(null);
const [formState, setFormState] = useState({
body: "",
title: "",
});
const createPost = api.post.create.useMutation({
onSuccess: async () => {
setFormState({
body: "",
title: "",
});
setMessage("Post created.");
await Promise.all([utils.post.list.invalidate(), utils.post.mine.invalidate()]);
},
onError: (error) => {
setMessage(error.message);
},
});
return (
<section className="rounded-[28px] border border-white/12 bg-[#0a1220]/85 p-6 shadow-[0_18px_70px_rgba(2,6,23,0.24)]">
<div className="flex flex-col gap-2">
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.28em]">Protected write route</p>
<h2 className="text-2xl tracking-[-0.04em]">Create a post</h2>
<p className="text-[#c5d0ea] text-sm leading-7">
This form calls the protected `post.create` tRPC mutation with the signed-in user.
</p>
</div>
<form
className="mt-6 space-y-4"
onSubmit={(event) => {
event.preventDefault();
setMessage(null);
createPost.mutate(formState);
}}
>
<label className="block space-y-2">
<span className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.24em]">Title</span>
<input
className={inputClassName}
maxLength={160}
onChange={(event) =>
setFormState((current) => ({
...current,
title: event.target.value,
}))
}
placeholder="Building the first route in a fresh app"
required
type="text"
value={formState.title}
/>
</label>
<label className="block space-y-2">
<span className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.24em]">Body</span>
<textarea
className={`${inputClassName} min-h-40 resize-y`}
maxLength={10000}
onChange={(event) =>
setFormState((current) => ({
...current,
body: event.target.value,
}))
}
placeholder="Describe a small workflow, a data model, or a route that helped you validate the scaffold."
required
value={formState.body}
/>
</label>
{message ? (
<div className="rounded-[18px] border border-white/10 bg-white/[0.04] px-4 py-3 text-[#dbe4ff] text-sm">{message}</div>
) : null}
<button
className="rounded-[18px] bg-[#9db6ff] px-4 py-3 font-mono text-[#09111d] text-[11px] uppercase tracking-[0.24em] transition hover:bg-[#b8cbff] disabled:cursor-not-allowed disabled:bg-[#556888] disabled:text-[#d4def8]"
disabled={createPost.isPending}
type="submit"
>
{createPost.isPending ? "Creating..." : "Create post"}
</button>
</form>
</section>
);
};

View File

@@ -0,0 +1,27 @@
import { PostCard } from "~/components/PostCard";
import type { RouterOutputs } from "~/utils/api";
type PostListProps = {
emptyTitle: string;
emptyText: string;
posts: RouterOutputs["post"]["list"];
};
export const PostList = ({ emptyText, emptyTitle, posts }: PostListProps) => {
if (posts.length === 0) {
return (
<div className="rounded-[28px] border border-white/16 border-dashed bg-white/[0.04] px-6 py-12 text-center">
<h2 className="text-2xl tracking-[-0.04em]">{emptyTitle}</h2>
<p className="mx-auto mt-4 max-w-xl text-[#c2cee8] text-sm leading-7">{emptyText}</p>
</div>
);
}
return (
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
};

20
src/env.js Normal file
View File

@@ -0,0 +1,20 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.url(),
BETTER_AUTH_SECRET: z.string().trim().min(32),
BETTER_AUTH_URL: z.url(),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
},
client: {},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
NODE_ENV: process.env.NODE_ENV,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,
});

27
src/pages/_app.tsx Normal file
View File

@@ -0,0 +1,27 @@
import type { AppType } from "next/app";
import { IBM_Plex_Mono, Space_Grotesk } from "next/font/google";
import { api } from "~/utils/api";
import "~/styles/globals.css";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
variable: "--font-space-grotesk",
});
const ibmPlexMono = IBM_Plex_Mono({
subsets: ["latin"],
variable: "--font-ibm-plex-mono",
weight: ["400", "500"],
});
const MyApp: AppType = ({ Component, pageProps }) => {
return (
<div className={`${spaceGrotesk.variable} ${ibmPlexMono.variable} font-sans`}>
<Component {...pageProps} />
</div>
);
};
export default api.withTRPC(MyApp);

110
src/pages/account.tsx Normal file
View File

@@ -0,0 +1,110 @@
import type { GetServerSideProps } from "next";
import Head from "next/head";
import { useRouter } from "next/router";
import { startTransition, useState } from "react";
import { PageShell } from "~/components/PageShell";
import { requireAuthenticatedPage } from "~/server/page-auth";
import { signOut, useSession } from "~/utils/auth-client";
import { formatDateTime } from "~/utils/formatting";
const actionClassName =
"rounded-full bg-[#9db6ff] px-4 py-3 font-mono text-[11px] uppercase tracking-[0.24em] text-[#09111d] transition hover:bg-[#b8cbff] disabled:cursor-not-allowed disabled:bg-[#556888] disabled:text-[#d4def8]";
export default function AccountPage() {
const router = useRouter();
const { data: session, isPending: isSessionPending } = useSession();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSigningOut, setIsSigningOut] = useState(false);
const handleSignOut = async () => {
setErrorMessage(null);
setIsSigningOut(true);
try {
await signOut();
startTransition(() => {
void router.replace("/");
});
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "Unable to sign out.");
} finally {
setIsSigningOut(false);
}
};
return (
<>
<Head>
<title>Account | Web Boilerplate</title>
<meta content="Protected account route for the Better Auth session." name="description" />
</Head>
<PageShell
actions={
<button
className={actionClassName}
disabled={isSigningOut || isSessionPending}
onClick={() => void handleSignOut()}
type="button"
>
{isSigningOut ? "Signing out..." : "Sign out"}
</button>
}
eyebrow="Protected Route"
intro="This page is only reachable with a valid Better Auth session and mirrors the current session state directly from the client."
title="Account"
>
{errorMessage ? (
<div className="mb-6 rounded-[24px] border border-[#6f4348] bg-[#231115] px-5 py-4 text-[#ffccd2] text-sm">{errorMessage}</div>
) : null}
<section className="rounded-[30px] border border-white/12 bg-white/[0.05] p-6 shadow-[0_20px_80px_rgba(2,6,23,0.28)] md:p-8">
{session ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-[24px] border border-white/10 bg-[#0b1320]/80 p-5">
<p className="font-mono text-[#8ea5dd] text-[11px] uppercase tracking-[0.24em]">Profile</p>
<dl className="mt-4 space-y-4 text-[#d5def2] text-sm">
<div>
<dt className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.22em]">Name</dt>
<dd className="mt-1">{session.user.name}</dd>
</div>
<div>
<dt className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.22em]">Email</dt>
<dd className="mt-1">{session.user.email}</dd>
</div>
<div>
<dt className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.22em]">Verified</dt>
<dd className="mt-1">{session.user.emailVerified ? "Yes" : "No"}</dd>
</div>
</dl>
</div>
<div className="rounded-[24px] border border-white/10 bg-[#0b1320]/80 p-5">
<p className="font-mono text-[#8ea5dd] text-[11px] uppercase tracking-[0.24em]">Session</p>
<dl className="mt-4 space-y-4 text-[#d5def2] text-sm">
<div>
<dt className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.22em]">Session ID</dt>
<dd className="mt-1 break-all">{session.session.id}</dd>
</div>
<div>
<dt className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.22em]">Expires</dt>
<dd className="mt-1">{formatDateTime(session.session.expiresAt)}</dd>
</div>
<div>
<dt className="font-mono text-[#8f9ab6] text-[11px] uppercase tracking-[0.22em]">User ID</dt>
<dd className="mt-1 break-all">{session.user.id}</dd>
</div>
</dl>
</div>
</div>
) : (
<div className="h-56 animate-pulse rounded-[24px] border border-white/10 bg-white/[0.05]" />
)}
</section>
</PageShell>
</>
);
}
export const getServerSideProps: GetServerSideProps = requireAuthenticatedPage;

View File

@@ -0,0 +1,11 @@
import { toNodeHandler } from "better-auth/node";
import { auth } from "~/server/auth";
export const config = {
api: {
bodyParser: false,
},
};
export default toNodeHandler(auth);

View File

@@ -0,0 +1,16 @@
import { createNextApiHandler } from "@trpc/server/adapters/next";
import { env } from "~/env";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
export default createNextApiHandler({
router: appRouter,
createContext: createTRPCContext,
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`);
}
: undefined,
});

71
src/pages/dashboard.tsx Normal file
View File

@@ -0,0 +1,71 @@
import type { GetServerSideProps } from "next";
import Head from "next/head";
import { PageShell } from "~/components/PageShell";
import { PostForm } from "~/components/PostForm";
import { PostList } from "~/components/PostList";
import { requireAuthenticatedPage } from "~/server/page-auth";
import { api } from "~/utils/api";
import { useSession } from "~/utils/auth-client";
const dashboardSkeletonKeys = ["mine-loading-1", "mine-loading-2"] as const;
export default function DashboardPage() {
const { data: session } = useSession();
const myPostsQuery = api.post.mine.useQuery();
return (
<>
<Head>
<title>Dashboard | Web Boilerplate</title>
<meta content="Protected dashboard route for post creation." name="description" />
</Head>
<PageShell
eyebrow="Protected Route"
intro="This page is guarded by `getServerSideProps` and uses a protected tRPC query for the current user's posts."
title={session ? `Dashboard for ${session.user.name}` : "Dashboard"}
>
<div className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<PostForm />
<section className="rounded-[28px] border border-white/12 bg-white/[0.05] p-6 shadow-[0_18px_70px_rgba(2,6,23,0.24)]">
<div className="flex flex-col gap-2">
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.28em]">Protected read route</p>
<h2 className="text-2xl tracking-[-0.04em]">Your posts</h2>
<p className="text-[#c5d0ea] text-sm leading-7">
This panel calls `post.mine`, which only resolves when a valid session is present.
</p>
</div>
<div className="mt-6">
{myPostsQuery.error ? (
<div className="rounded-[18px] border border-[#6f4348] bg-[#231115] px-4 py-3 text-[#ffccd2] text-sm">
Failed to load your posts: {myPostsQuery.error.message}
</div>
) : null}
{myPostsQuery.isPending ? (
<div className="space-y-4">
{dashboardSkeletonKeys.map((key) => (
<div className="h-40 animate-pulse rounded-[24px] border border-white/10 bg-white/[0.05]" key={key} />
))}
</div>
) : null}
{myPostsQuery.data ? (
<PostList
emptyText="You have access to the dashboard, but you have not created any posts yet."
emptyTitle="No personal posts yet"
posts={myPostsQuery.data}
/>
) : null}
</div>
</section>
</div>
</PageShell>
</>
);
}
export const getServerSideProps: GetServerSideProps = requireAuthenticatedPage;

131
src/pages/index.tsx Normal file
View File

@@ -0,0 +1,131 @@
import Head from "next/head";
import Link from "next/link";
import { PageShell } from "~/components/PageShell";
import { PostList } from "~/components/PostList";
import { api } from "~/utils/api";
import { useSession } from "~/utils/auth-client";
const featureCards = [
{
label: "Public routes",
title: "Posts are readable without auth",
description: "The home page, posts index, and post detail route are intentionally open so a new app has a public surface immediately.",
},
{
label: "Protected routes",
title: "Writes require Better Auth",
description: "Dashboard, account, post creation, and comment creation all depend on a valid Better Auth email/password session.",
},
{
label: "Typed server",
title: "tRPC and Prisma stay thin",
description:
"Data fetching stays in typed routers and services, leaving the page layer focused on small UI states and empty-state handling.",
},
] as const;
const previewSkeletonKeys = ["home-loading-1", "home-loading-2", "home-loading-3"] as const;
export default function HomePage() {
const { data: session } = useSession();
const postsQuery = api.post.list.useQuery({ limit: 3 });
return (
<>
<Head>
<title>Web Boilerplate</title>
<meta content="Generic Next.js boilerplate with Better Auth, tRPC, and Prisma." name="description" />
</Head>
<PageShell
actions={
<>
<Link
className="rounded-full bg-[#9db6ff] px-4 py-3 font-mono text-[#09111d] text-[11px] uppercase tracking-[0.24em] transition hover:bg-[#b8cbff]"
href={session ? "/dashboard" : "/sign-up"}
>
{session ? "Open dashboard" : "Create account"}
</Link>
<Link
className="rounded-full border border-white/12 px-4 py-3 font-mono text-[#d7e0f4] text-[11px] uppercase tracking-[0.24em] transition hover:border-white/40 hover:bg-white/8"
href="/posts"
>
Browse posts
</Link>
</>
}
eyebrow="Shared Stack"
intro="A generic Pages Router app that mirrors the reference stack without carrying over any music or audio behavior."
title="Start from typed routes, auth gates, and clean empty states"
>
<section className="grid gap-4 lg:grid-cols-[1.25fr_0.95fr]">
<div className="rounded-[30px] border border-white/12 bg-white/[0.05] p-6 shadow-[0_20px_80px_rgba(2,6,23,0.28)] md:p-8">
<div className="flex flex-col gap-3">
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.28em]">What is included</p>
<h2 className="text-3xl tracking-[-0.05em]">Better Auth, Prisma, tRPC, and a simple post/comment relationship</h2>
<p className="max-w-2xl text-[#c2cee8] text-sm leading-7">
Use this as the first pass for a typed internal tool or product prototype. The public routes stay open, the protected flows
stay strict, and the database starts with two clear relational models.
</p>
</div>
<div className="mt-8 grid gap-4 md:grid-cols-3">
{featureCards.map((card) => (
<article className="rounded-[24px] border border-white/10 bg-[#0b1320]/80 p-5" key={card.title}>
<p className="font-mono text-[#8ea5dd] text-[11px] uppercase tracking-[0.24em]">{card.label}</p>
<h3 className="mt-3 text-xl tracking-[-0.04em]">{card.title}</h3>
<p className="mt-3 text-[#b9c6e4] text-sm leading-7">{card.description}</p>
</article>
))}
</div>
</div>
<div className="rounded-[30px] border border-white/12 bg-[#0a1220]/88 p-6 shadow-[0_20px_80px_rgba(2,6,23,0.28)] md:p-8">
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.28em]">Current state</p>
<h2 className="mt-3 text-2xl tracking-[-0.04em]">
{session ? "You can use the protected routes now" : "Sign in only when you need protected routes"}
</h2>
<p className="mt-4 text-[#c2cee8] text-sm leading-7">
{session
? "Your session is active, so the dashboard can create posts and the post detail page can submit comments."
: "You can browse everything public first. When you need writes, sign up with Better Auth email/password and continue into the dashboard."}
</p>
<div className="mt-8 rounded-[24px] border border-white/16 border-dashed bg-white/[0.03] p-5">
<p className="font-mono text-[#8ea5dd] text-[11px] uppercase tracking-[0.24em]">Preview query</p>
<p className="mt-3 text-[#b9c6e4] text-sm leading-7">
The list below calls the public `post.list` procedure. With a fresh database, it should stay calm and render a deliberate
empty state.
</p>
</div>
</div>
</section>
<section className="mt-8">
{postsQuery.error ? (
<div className="rounded-[24px] border border-[#6f4348] bg-[#231115] px-5 py-4 text-[#ffccd2] text-sm">
Failed to load posts: {postsQuery.error.message}
</div>
) : null}
{postsQuery.isPending ? (
<div className="grid gap-4 lg:grid-cols-3">
{previewSkeletonKeys.map((key) => (
<div className="h-64 animate-pulse rounded-[26px] border border-white/10 bg-white/[0.05]" key={key} />
))}
</div>
) : null}
{postsQuery.data ? (
<PostList
emptyText="There are no posts yet. Sign in, create one from the dashboard, and the public routes will update automatically."
emptyTitle="No posts have been published yet"
posts={postsQuery.data}
/>
) : null}
</section>
</PageShell>
</>
);
}

View File

@@ -0,0 +1,139 @@
import type { GetServerSideProps } from "next";
import Head from "next/head";
import Link from "next/link";
import { CommentForm } from "~/components/CommentForm";
import { PageShell } from "~/components/PageShell";
import { api } from "~/utils/api";
import { useSession } from "~/utils/auth-client";
import { formatDateTime } from "~/utils/formatting";
type PostDetailPageProps = {
postId: string;
};
export default function PostDetailPage({ postId }: PostDetailPageProps) {
const { data: session, isPending: isSessionPending } = useSession();
const postQuery = api.post.byId.useQuery({ postId });
const post = postQuery.data;
return (
<>
<Head>
<title>{post ? `${post.title} | Web Boilerplate` : "Post | Web Boilerplate"}</title>
<meta content="Public post detail route with protected comment creation." name="description" />
</Head>
<PageShell
eyebrow="Public Route"
intro="The post itself stays readable for everyone. The comment form only becomes available once Better Auth resolves a valid session."
title={post ? post.title : "Post detail"}
>
{postQuery.isPending ? (
<div className="space-y-4">
<div className="h-72 animate-pulse rounded-[28px] border border-white/10 bg-white/[0.05]" />
<div className="h-56 animate-pulse rounded-[28px] border border-white/10 bg-white/[0.05]" />
</div>
) : null}
{postQuery.error ? (
<div className="rounded-[24px] border border-[#6f4348] bg-[#231115] px-5 py-4 text-[#ffccd2] text-sm">
Failed to load the post: {postQuery.error.message}
</div>
) : null}
{!postQuery.isPending && !postQuery.error && !post ? (
<div className="rounded-[28px] border border-white/16 border-dashed bg-white/[0.04] px-6 py-12 text-center">
<h2 className="text-2xl tracking-[-0.04em]">This post does not exist</h2>
<p className="mx-auto mt-4 max-w-xl text-[#c2cee8] text-sm leading-7">
The route is valid, but there is no database record for this post ID. That is expected for an unknown link.
</p>
<Link className="mt-6 inline-flex font-mono text-[#9db6ff] text-[11px] uppercase tracking-[0.24em]" href="/posts">
Back to posts
</Link>
</div>
) : null}
{post ? (
<div className="space-y-6">
<article className="rounded-[30px] border border-white/12 bg-white/[0.05] p-6 shadow-[0_20px_80px_rgba(2,6,23,0.28)] md:p-8">
<div className="flex flex-wrap items-center gap-3 text-[#a5b1cf] text-sm">
<span className="rounded-full border border-white/10 px-3 py-1 font-mono text-[#8ea5dd] text-[10px] uppercase tracking-[0.22em]">
{post.author.name}
</span>
<span>{formatDateTime(post.createdAt)}</span>
<span>{post._count.comments} comments</span>
</div>
<div className="mt-6 whitespace-pre-wrap text-[#d5def2] text-[15px] leading-8">{post.body}</div>
</article>
<section className="rounded-[30px] border border-white/12 bg-[#0a1220]/88 p-6 shadow-[0_20px_80px_rgba(2,6,23,0.28)] md:p-8">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.28em]">Comments</p>
<h2 className="mt-3 text-2xl tracking-[-0.04em]">Discussion on this post</h2>
</div>
</div>
<div className="mt-6 space-y-4">
{post.comments.length === 0 ? (
<div className="rounded-[24px] border border-white/16 border-dashed bg-white/[0.04] px-5 py-8 text-center text-[#c2cee8] text-sm leading-7">
No comments yet. The first signed-in user can leave one below.
</div>
) : (
post.comments.map((comment) => (
<article className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5" key={comment.id}>
<div className="flex flex-wrap items-center gap-3 text-[#a5b1cf] text-sm">
<span className="font-mono text-[#8ea5dd] text-[11px] uppercase tracking-[0.22em]">{comment.author.name}</span>
<span>{formatDateTime(comment.createdAt)}</span>
</div>
<p className="mt-3 whitespace-pre-wrap text-[#d5def2] text-sm leading-7">{comment.body}</p>
</article>
))
)}
</div>
</section>
{session ? (
<CommentForm postId={postId} />
) : isSessionPending ? (
<div className="h-52 animate-pulse rounded-[26px] border border-white/10 bg-white/[0.05]" />
) : (
<section className="rounded-[26px] border border-white/12 bg-[#0a1220]/85 p-6 text-center">
<p className="font-mono text-[#99b6ff] text-[11px] uppercase tracking-[0.24em]">Protected write route</p>
<h2 className="mt-3 text-xl tracking-[-0.04em]">Sign in to comment</h2>
<p className="mt-3 text-[#c5d0ea] text-sm leading-7">
Reading stays public, but posting a comment requires a Better Auth session.
</p>
<Link
className="mt-5 inline-flex rounded-full bg-[#9db6ff] px-4 py-3 font-mono text-[#09111d] text-[11px] uppercase tracking-[0.24em] transition hover:bg-[#b8cbff]"
href={`/sign-in?next=${encodeURIComponent(`/posts/${postId}`)}`}
>
Sign in to comment
</Link>
</section>
)}
</div>
) : null}
</PageShell>
</>
);
}
export const getServerSideProps: GetServerSideProps<PostDetailPageProps> = async (context) => {
const postId = context.params?.postId;
if (typeof postId !== "string" || postId.trim().length === 0) {
return {
notFound: true,
};
}
return {
props: {
postId,
},
};
};

55
src/pages/posts/index.tsx Normal file
View File

@@ -0,0 +1,55 @@
import Head from "next/head";
import { PageShell } from "~/components/PageShell";
import { PostList } from "~/components/PostList";
import { api } from "~/utils/api";
const postSkeletonKeys = [
"posts-loading-1",
"posts-loading-2",
"posts-loading-3",
"posts-loading-4",
"posts-loading-5",
"posts-loading-6",
] as const;
export default function PostsPage() {
const postsQuery = api.post.list.useQuery({ limit: 24 });
return (
<>
<Head>
<title>Posts | Web Boilerplate</title>
<meta content="Public post index for the generic Better Auth boilerplate." name="description" />
</Head>
<PageShell
eyebrow="Public Route"
intro="This page is intentionally unprotected. It should still be useful with an empty database and predictable after new posts are created."
title="Browse the full post index"
>
{postsQuery.error ? (
<div className="rounded-[24px] border border-[#6f4348] bg-[#231115] px-5 py-4 text-[#ffccd2] text-sm">
Failed to load posts: {postsQuery.error.message}
</div>
) : null}
{postsQuery.isPending ? (
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
{postSkeletonKeys.map((key) => (
<div className="h-64 animate-pulse rounded-[26px] border border-white/10 bg-white/[0.05]" key={key} />
))}
</div>
) : null}
{postsQuery.data ? (
<PostList
emptyText="This route is live, but the database is still empty. Create the first post from the protected dashboard."
emptyTitle="Nothing to show yet"
posts={postsQuery.data}
/>
) : null}
</PageShell>
</>
);
}

27
src/pages/sign-in.tsx Normal file
View File

@@ -0,0 +1,27 @@
import type { GetServerSideProps } from "next";
import Head from "next/head";
import { AuthForm } from "~/components/AuthForm";
import { PageShell } from "~/components/PageShell";
import { type AuthPageProps, redirectAuthenticatedFromAuthPages } from "~/server/page-auth";
export default function SignInPage({ nextPath }: AuthPageProps) {
return (
<>
<Head>
<title>Sign In | Web Boilerplate</title>
<meta content="Better Auth email and password sign-in route." name="description" />
</Head>
<PageShell
eyebrow="Authentication"
intro="This route stays public, but authenticated visitors are redirected back to the dashboard."
title="Sign in with Better Auth"
>
<AuthForm mode="sign-in" nextPath={nextPath} />
</PageShell>
</>
);
}
export const getServerSideProps: GetServerSideProps<AuthPageProps> = redirectAuthenticatedFromAuthPages;

27
src/pages/sign-up.tsx Normal file
View File

@@ -0,0 +1,27 @@
import type { GetServerSideProps } from "next";
import Head from "next/head";
import { AuthForm } from "~/components/AuthForm";
import { PageShell } from "~/components/PageShell";
import { type AuthPageProps, redirectAuthenticatedFromAuthPages } from "~/server/page-auth";
export default function SignUpPage({ nextPath }: AuthPageProps) {
return (
<>
<Head>
<title>Sign Up | Web Boilerplate</title>
<meta content="Better Auth email and password sign-up route." name="description" />
</Head>
<PageShell
eyebrow="Authentication"
intro="Create a user through Better Auth email and password, then land on the protected dashboard."
title="Create a Better Auth account"
>
<AuthForm mode="sign-up" nextPath={nextPath} />
</PageShell>
</>
);
}
export const getServerSideProps: GetServerSideProps<AuthPageProps> = redirectAuthenticatedFromAuthPages;

12
src/server/api/root.ts Normal file
View File

@@ -0,0 +1,12 @@
import { commentRouter } from "~/server/api/routers/comment";
import { postRouter } from "~/server/api/routers/post";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
export const appRouter = createTRPCRouter({
comment: commentRouter,
post: postRouter,
});
export type AppRouter = typeof appRouter;
export const createCaller = createCallerFactory(appRouter);

View File

@@ -0,0 +1,31 @@
import { TRPCError } from "@trpc/server";
import { createCommentInputSchema } from "~/server/api/schemas/post";
import { createComment } from "~/server/api/services/comment";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
export const commentRouter = createTRPCRouter({
create: protectedProcedure.input(createCommentInputSchema).mutation(async ({ ctx, input }) => {
const post = await ctx.db.post.findUnique({
where: {
id: input.postId,
},
select: {
id: true,
},
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found.",
});
}
return createComment(ctx.db, {
authorId: ctx.session.user.id,
body: input.body,
postId: input.postId,
});
}),
});

View File

@@ -0,0 +1,18 @@
import { createPostInputSchema, getPostByIdInputSchema, listPostsInputSchema } from "~/server/api/schemas/post";
import { createPost, getPostById, listMyPosts, listPosts } from "~/server/api/services/post";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc";
export const postRouter = createTRPCRouter({
list: publicProcedure.input(listPostsInputSchema.optional()).query(({ ctx, input }) => listPosts(ctx.db, input)),
byId: publicProcedure.input(getPostByIdInputSchema).query(({ ctx, input }) => getPostById(ctx.db, input.postId)),
mine: protectedProcedure.query(({ ctx }) => listMyPosts(ctx.db, ctx.session.user.id)),
create: protectedProcedure.input(createPostInputSchema).mutation(({ ctx, input }) =>
createPost(ctx.db, {
...input,
authorId: ctx.session.user.id,
}),
),
});

View File

@@ -0,0 +1,40 @@
import { z } from "zod";
const nonEmptyString = (field: string) => z.string().trim().min(1, `${field} is required`);
const blankStringToUndefined = (value: unknown) => {
if (value === null || value === undefined) {
return undefined;
}
if (typeof value !== "string") {
return value;
}
const trimmedValue = value.trim();
return trimmedValue.length > 0 ? trimmedValue : undefined;
};
export const postIdSchema = nonEmptyString("Post ID");
export const listPostsInputSchema = z.object({
limit: z.coerce.number().int().min(1).max(50).optional(),
});
export const getPostByIdInputSchema = z.object({
postId: postIdSchema,
});
export const createPostInputSchema = z.object({
title: nonEmptyString("Title").max(160, "Title must be 160 characters or fewer"),
body: nonEmptyString("Body").max(10_000, "Body must be 10000 characters or fewer"),
});
export const createCommentInputSchema = z.object({
postId: postIdSchema,
body: z.preprocess(blankStringToUndefined, nonEmptyString("Comment").max(4_000, "Comment must be 4000 characters or fewer")),
});
export type ListPostsInput = z.infer<typeof listPostsInputSchema>;
export type CreatePostInput = z.infer<typeof createPostInputSchema>;
export type CreateCommentInput = z.infer<typeof createCommentInputSchema>;

View File

@@ -0,0 +1,30 @@
import type { Prisma, PrismaClient } from "@/db";
type DatabaseClient = Pick<PrismaClient, "comment">;
const authorSelect = {
id: true,
name: true,
email: true,
} satisfies Prisma.UserSelect;
const commentSelect = {
id: true,
body: true,
createdAt: true,
updatedAt: true,
author: {
select: authorSelect,
},
} satisfies Prisma.CommentSelect;
export const createComment = (db: DatabaseClient, input: { authorId: string; body: string; postId: string }) => {
return db.comment.create({
data: {
authorId: input.authorId,
body: input.body,
postId: input.postId,
},
select: commentSelect,
});
};

View File

@@ -0,0 +1,100 @@
import type { Prisma, PrismaClient } from "@/db";
import type { CreatePostInput, ListPostsInput } from "~/server/api/schemas/post";
type DatabaseClient = Pick<PrismaClient, "post">;
const authorSelect = {
id: true,
name: true,
email: true,
} satisfies Prisma.UserSelect;
const commentSelect = {
id: true,
body: true,
createdAt: true,
updatedAt: true,
author: {
select: authorSelect,
},
} satisfies Prisma.CommentSelect;
const postCardSelect = {
id: true,
title: true,
body: true,
createdAt: true,
updatedAt: true,
author: {
select: authorSelect,
},
_count: {
select: {
comments: true,
},
},
} satisfies Prisma.PostSelect;
const postDetailSelect = {
id: true,
title: true,
body: true,
createdAt: true,
updatedAt: true,
author: {
select: authorSelect,
},
comments: {
orderBy: {
createdAt: "asc",
},
select: commentSelect,
},
_count: {
select: {
comments: true,
},
},
} satisfies Prisma.PostSelect;
export const listPosts = (db: DatabaseClient, input: ListPostsInput = {}) => {
return db.post.findMany({
orderBy: {
createdAt: "desc",
},
take: input.limit ?? 12,
select: postCardSelect,
});
};
export const getPostById = (db: DatabaseClient, postId: string) => {
return db.post.findUnique({
where: {
id: postId,
},
select: postDetailSelect,
});
};
export const listMyPosts = (db: DatabaseClient, authorId: string) => {
return db.post.findMany({
where: {
authorId,
},
orderBy: {
createdAt: "desc",
},
select: postCardSelect,
});
};
export const createPost = (db: DatabaseClient, input: CreatePostInput & { authorId: string }) => {
return db.post.create({
data: {
title: input.title,
body: input.body,
authorId: input.authorId,
},
select: postCardSelect,
});
};

74
src/server/api/trpc.ts Normal file
View File

@@ -0,0 +1,74 @@
import { initTRPC, TRPCError } from "@trpc/server";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import superjson from "superjson";
import { ZodError } from "zod";
import { getNodeSession } from "~/server/auth";
import { db } from "~/server/db";
type CreateContextOptions = {
headers: CreateNextContextOptions["req"]["headers"];
};
const createInnerTRPCContext = async (opts: CreateContextOptions) => {
const session = await getNodeSession(opts.headers);
return {
db,
session,
};
};
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
return createInnerTRPCContext({
headers: opts.req.headers,
});
};
type TRPCContext = Awaited<ReturnType<typeof createTRPCContext>>;
const t = initTRPC.context<TRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createCallerFactory = t.createCallerFactory;
export const createTRPCRouter = t.router;
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
if (t._config.isDev) {
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
const result = await next();
console.log(`[TRPC] ${path} took ${Date.now() - start}ms to execute`);
return result;
});
export const publicProcedure = t.procedure.use(timingMiddleware);
export const protectedProcedure = t.procedure.use(timingMiddleware).use(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be signed in to perform this action.",
});
}
return next({
ctx: {
session: ctx.session,
},
});
});

31
src/server/auth.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { IncomingHttpHeaders } from "node:http";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { fromNodeHeaders } from "better-auth/node";
import { env } from "~/env";
import { db } from "~/server/db";
const buildFallbackAuthUrl = "http://localhost:3456";
const buildFallbackAuthSecret = "build-only-better-auth-secret-should-be-overridden";
const resolvedBaseUrl = process.env.BETTER_AUTH_URL ?? env.BETTER_AUTH_URL ?? buildFallbackAuthUrl;
const resolvedSecret = process.env.BETTER_AUTH_SECRET ?? env.BETTER_AUTH_SECRET ?? buildFallbackAuthSecret;
export const auth = betterAuth({
database: prismaAdapter(db, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
},
baseURL: resolvedBaseUrl,
secret: resolvedSecret,
trustedOrigins: [resolvedBaseUrl],
});
export const getNodeSession = async (headers: IncomingHttpHeaders) => {
return auth.api.getSession({
headers: fromNodeHeaders(headers),
});
};

25
src/server/db.ts Normal file
View File

@@ -0,0 +1,25 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/db";
import { env } from "~/env";
const buildFallbackDatabaseUrl = "postgresql://postgres:postgres@localhost:5432/web_boilerplate";
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL ?? env.DATABASE_URL ?? buildFallbackDatabaseUrl,
});
const createPrismaClient = () =>
new PrismaClient({
adapter,
log: env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
const globalForPrisma = globalThis as unknown as {
prisma: ReturnType<typeof createPrismaClient> | undefined;
};
export const db = globalForPrisma.prisma ?? createPrismaClient();
if (env.NODE_ENV !== "production") {
globalForPrisma.prisma = db;
}

53
src/server/page-auth.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
import { getNodeSession } from "~/server/auth";
import { DEFAULT_AUTH_REDIRECT, getSafeNextPath } from "~/utils/next-path";
export type AuthPageProps = {
nextPath: string;
};
const buildSignInDestination = (resolvedUrl: string) => {
const nextPath = getSafeNextPath(resolvedUrl, DEFAULT_AUTH_REDIRECT);
return `/sign-in?next=${encodeURIComponent(nextPath)}`;
};
export const requireAuthenticatedPage = async (
context: GetServerSidePropsContext,
): Promise<GetServerSidePropsResult<Record<string, never>>> => {
const session = await getNodeSession(context.req.headers);
if (!session) {
return {
redirect: {
destination: buildSignInDestination(context.resolvedUrl),
permanent: false,
},
};
}
return {
props: {},
};
};
export const redirectAuthenticatedFromAuthPages = async (
context: GetServerSidePropsContext,
): Promise<GetServerSidePropsResult<AuthPageProps>> => {
const session = await getNodeSession(context.req.headers);
if (session) {
return {
redirect: {
destination: DEFAULT_AUTH_REDIRECT,
permanent: false,
},
};
}
return {
props: {
nextPath: getSafeNextPath(context.query.next),
},
};
};

32
src/styles/globals.css Normal file
View File

@@ -0,0 +1,32 @@
@import "tailwindcss";
@theme {
--font-sans: var(--font-space-grotesk), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-ibm-plex-mono), ui-monospace, monospace;
}
:root {
color-scheme: dark;
}
html {
min-height: 100%;
background: #050910;
}
body {
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(103, 145, 255, 0.18), transparent 34%),
linear-gradient(180deg, #08111b 0%, #0d1522 45%, #060b12 100%);
color: #f3f6ff;
}
a {
color: inherit;
}
::selection {
background: rgba(157, 182, 255, 0.35);
color: #f8fbff;
}

39
src/utils/api.ts Normal file
View File

@@ -0,0 +1,39 @@
import { httpBatchLink, loggerLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import superjson from "superjson";
import type { AppRouter } from "~/server/api/root";
const getBaseUrl = () => {
if (typeof window !== "undefined") {
return "";
}
if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`;
}
return `http://localhost:${process.env.PORT ?? 3000}`;
};
export const api = createTRPCNext<AppRouter>({
config() {
return {
links: [
loggerLink({
enabled: (opts) => process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
}),
httpBatchLink({
transformer: superjson,
url: `${getBaseUrl()}/api/trpc`,
}),
],
};
},
ssr: false,
transformer: superjson,
});
export type RouterInputs = inferRouterInputs<AppRouter>;
export type RouterOutputs = inferRouterOutputs<AppRouter>;

5
src/utils/auth-client.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
export const { signIn, signOut, signUp, useSession } = authClient;

14
src/utils/formatting.ts Normal file
View File

@@ -0,0 +1,14 @@
export const formatDateTime = (value: Date | string) => {
return new Intl.DateTimeFormat("en", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
};
export const truncateText = (value: string, maxLength: number) => {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 3).trimEnd()}...`;
};

14
src/utils/next-path.ts Normal file
View File

@@ -0,0 +1,14 @@
export const DEFAULT_AUTH_REDIRECT = "/dashboard";
export const getSafeNextPath = (value: string | string[] | undefined, fallback = DEFAULT_AUTH_REDIRECT) => {
if (typeof value !== "string") {
return fallback;
}
const normalizedValue = value.trim();
if (!normalizedValue.startsWith("/") || normalizedValue.startsWith("//")) {
return fallback;
}
return normalizedValue;
};

32
tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"plugins": [
{
"name": "next"
}
],
"incremental": false,
"paths": {
"~/*": ["./src/*"],
"@/db": ["./generated/prisma/client"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.js", ".next/types/**/*.ts"],
"exclude": ["node_modules", "generated", "index.js", "eslint.config.mjs"]
}