ECE1724: Advanced Web Development

React Ecosystem and Modern Frameworks

Chen Ying

Assistant Professor, Teaching Stream

Department of Electrical and Computer Engineering

University of Toronto

Last Week’s Lecture

Navigation in Next.js

  • Link: Preferred for page navigation
  • useRouter: Programmatic navigation in Client Components
  • usePathname: Read the current URL

App Router Conventions

  • page.tsx: Page content
  • layout.tsx: Shared layout
  • route.ts: API endpoint

Last Week’s Lecture

Route Handlers + Prisma ORM

  • Build API endpoints in app/api/
  • Use Prisma-generated types

Server Components: Can fetch data directly without useEffect

  • await pauses rendering of a Server Component

Last Week’s Lecture

<Suspense>: Defines a loading boundary

  • Displays a fallback UI when its children are loading
  • Allows other parts of the page to render earlier

Next.js Rendering Modes

  • Static Rendering: Generated at build time

  • Dynamic Rendering: Generated per request at runtime

    export const dynamic = "force-dynamic";

Last Week’s Lecture

Server Actions: Asynchronous functions that run on the server

  • Marked with "use server"
  • Can be called from Server Components and Client Components
  • Often used for form submissions and data mutations
  • Reduce the need for separate API routes

Improve user experience with React hooks

  • useState, useTransition, useRouter

Progressive Enhancement

A web development strategy that starts with a functional baseline

  • Start with a version that works with basic web features
  • Add richer behavior when JavaScript is available

Progressive Enhancement

  1. Core Functionality: Ensure the app works without JavaScript (e.g., for users with JS disabled, slow networks, or older devices)
  2. Enhanced Experience: When JavaScript and modern browser features are available, add interactivity, animations, real-time updates

Progressive Enhancement

Baseline Version (Server Component) can work even before client-side JavaScript finishes loading

  • Submit form data (HTML <form>)
  • Run server-side logic (Server Actions)
    • Update database, redirect

Enhanced Version: JavaScript enhances UX

  • useTransition: Diable button during submission
  • useState: Display success/error message
  • useRouter: Redirect after delay

Progressive Enhancement

Progressive enhancement improves

  • Resilience: Core functionality still works if client JS is slow or unavailable
  • Accessibility: Works with simpler browsing environments (e.g., JS disabled)
  • User Experience: JavaScript can still provide a smoother interface

Full-Stack Application

Backend: API Route (app/api/posts/route.ts)

  • Handles Prisma queries to fetch posts from PostgreSQL

“Frontend”: A Server Component (app/posts/page.tsx)

  • fetch from /api/posts
  • Generates UI for the page
  • Uses dynamic = "force-dynamic" for runtime data retrieval

Frontend?

Server Components generate UI of the page

  • But they run on the server

Server Components are not purely frontend code

In Next.js, the boundary between “frontend” and “backend” becomes less obvious

Blurry Boundary

A Better Mental Model

Instead of thinking frontend vs. backend, think in layers:

Rendering Layer

Think Server Components as the rendering layer

  • They bridge backend and client-side display
Browser
   ↑
HTML from Server Component
   ↑
Server Component
   ↑
API Route
   ↑
Database

Server Components fetch data, generate HTML, and send the result to the browser

Next.js Application

Today’s Lecture

Cloud Storage

Store Files

Many applications need to store files

  • Images for posts or products, audio/video files

Should files go in PostgreSQL/SQLite?

  • No. Relational databases are best for structured data
    • Examples: Users, posts, paper records, file metadata
  • Large binary files are usually stored in object storage
    • Examples: Images, videos, PDFs, uploaded documents

Database vs. Object Storage

Database: Structured records

  • Filter, sort, join, …

Object Storage: Large files

  • Durable remote storage
  • Each file stored as an object with a key

A common pattern:

  • Store the file in cloud storage
  • Store its URL or key in the database

Cloud Storage

Storing data on remote servers instead of local devices

Benefits

  • Scalability: Handles growing file sizes without server upgrades
  • Reliability: Offload storage from local devices
  • Accessibility: Serve files globally

Content Delivery Network (CDN)

A network of servers that cache and deliver content closer to users

User requests a file → Routed to the nearest CDN edge server

Benefits

  • Reduces latency
  • Helps handle high traffic and global distribution

Cloud Storage + CDN

Without CDN

  • All requests go to a single storage location (slower)

With CDN

  • Frequently accessed files can be served from nearby cache locations
  • Only new or updated content is fetched from the origin

Especially useful for public assets such as images and downloadable files

DigitalOcean Spaces

S3-compatible object storage service by DigitalOcean

  • Based on the S3-compatible API model

S3-Compatible API Model

A standardized way to store and retrieve files (“objects”) over the network

  • Originally defined by Amazon S3 (Simple Storage Service)
  • Now an industry standard

Why S3 Matters

  • Provides a common, vendor-neutral API
  • Applications written for AWS S3 can run on other S3-compatible storage without code changes

S3: How it works

  • Interact using standard HTTP(S) requests
    • PUT, GET, DELETE
  • Authenticated with Access Key + Secret Key
  • Access through tools & SDKs

S3: Key Concepts

  • Bucket: A container for objects (like a top-level folder)
  • Object: A file (data + metadata) stored in a bucket
  • Key: Unique identifier (path/filename) of an object in a bucket
  • Endpoint: URL used to send API requests

DigitalOcean Spaces

S3-compatible object storage service by DigitalOcean

  • S3-compatible: Use Amazon S3 API ecosystem
  • Object storage: Store and manage data as objects (data, metadata, a unique identifier)
  • Built-in CDN support for faster delivery

Pricing

Set Up

  1. Sign into DigitalOcean (or create an account)
  2. Create a Space Bucket
    • DigitalOcean Dashboard: Spaces Object Storage → Create Bucket
    • Choose a region (e.g., TOR1)
    • Name it uniquely (e.g., next-app-files)
    • Enable CDN (optional for speed)

Set Up

  1. Generate Access Keys:
    • Go to Access Keys → Create Access Key
    • Save access key ID and secret key (securely!)

AWS SDK v3

A collection of libraries that allow developers to interact with AWS services programmatically

  • Provides methods to upload, download, and manage files in AWS S3

Install SDK

npm install @aws-sdk/client-s3

Configure .env

.env
SPACES_KEY=your-access-key
SPACES_SECRET=your-secret-key
SPACES_REGION=tor1
SPACES_BUCKET=next-app-files
SPACES_ENDPOINT=https://tor1.digitaloceanspaces.com

SPACES_ENDPOINT is the regional endpoint, not the bucket-specific URL

DigitalOcean uese the regional endpoint pattern ${REGION}.digitaloceanspaces.com

Create an S3 Client

lib/spaces.ts
import { S3Client } from "@aws-sdk/client-s3";

export const s3Client = new S3Client({
  endpoint: process.env.SPACES_ENDPOINT, // https://tor1.digitaloceanspaces.com
  region: process.env.SPACES_REGION, // tor1
  credentials: {
    accessKeyId: process.env.SPACES_KEY!,
    secretAccessKey: process.env.SPACES_SECRET!,
  },
});

! Operator

process.env.SPACES_KEY!
process.env.SPACES_SECRET!

Non-null assertion operator

  • Used when TypeScript can’t guarantee a value exists, but you know it does
  • “I am sure this value is not null or undefined
  • Common for required environment variables

Create an S3 Client

lib/spaces.ts
import { S3Client } from "@aws-sdk/client-s3";

export const s3Client = new S3Client({
  endpoint: process.env.SPACES_ENDPOINT, // https://tor1.digitaloceanspaces.com
  region: process.env.SPACES_REGION, // tor1
  credentials: {
    accessKeyId: process.env.SPACES_KEY!,
    secretAccessKey: process.env.SPACES_SECRET!,
  },
});

Route Handler

Build an endpoint api/upload to upload file to Spaces

app/api/upload/route.ts
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { type NextRequest, NextResponse } from "next/server";
import { s3Client } from "@/lib/spaces";

export async function POST(req: NextRequest) {
  const contentType = req.headers.get("content-type");
  if (!contentType || !contentType.includes("multipart/form-data")) {
    return NextResponse.json(
      { error: "Invalid content type" },
      { status: 400 },
    );
  }

  try {
    const formData = await req.formData();
    const file = formData.get("file") as File | null;

    if (!file) {
      return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
    }

    const fileBuffer = Buffer.from(await file.arrayBuffer());
    const key = `uploads/${Date.now()}-${file.name}`;

    const command = new PutObjectCommand({
      Bucket: process.env.SPACES_BUCKET, // next-app-files
      Key: key,
      Body: fileBuffer,
      ACL: "public-read",
      ContentType: file.type || "application/octet-stream",
    });

    await s3Client.send(command);

    const url = `https://${process.env.SPACES_BUCKET}.${process.env.SPACES_REGION}.digitaloceanspaces.com/${key}`;

    return NextResponse.json({ url });
  } catch (error) {
    console.error("Upload error:", error);
    return NextResponse.json(
      { error: "Failed to upload file" },
      { status: 500 },
    );
  }
}

Validate Request

File uploads are usually sent as multipart/form-data

export async function POST(req: NextRequest) {
  const contentType = req.headers.get("content-type");
  if (!contentType || !contentType.includes("multipart/form-data")) {
    return NextResponse.json(
      { error: "Invalid content type" },
      { status: 400 },
    );
  }

Ensures that the request is using multipart/form-data encoding

Route Handler

app/api/upload/route.ts
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { type NextRequest, NextResponse } from "next/server";
import { s3Client } from "@/lib/spaces";

export async function POST(req: NextRequest) {
  const contentType = req.headers.get("content-type");
  if (!contentType || !contentType.includes("multipart/form-data")) {
    return NextResponse.json(
      { error: "Invalid content type" },
      { status: 400 },
    );
  }

  try {
    const formData = await req.formData();
    const file = formData.get("file") as File | null;

    if (!file) {
      return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
    }

    const fileBuffer = Buffer.from(await file.arrayBuffer());
    const key = `uploads/${Date.now()}-${file.name}`;

    const command = new PutObjectCommand({
      Bucket: process.env.SPACES_BUCKET, // next-app-files
      Key: key,
      Body: fileBuffer,
      ACL: "public-read",
      ContentType: file.type || "application/octet-stream",
    });

    await s3Client.send(command);

    const url = `https://${process.env.SPACES_BUCKET}.${process.env.SPACES_REGION}.digitaloceanspaces.com/${key}`;

    return NextResponse.json({ url });
  } catch (error) {
    console.error("Upload error:", error);
    return NextResponse.json(
      { error: "Failed to upload file" },
      { status: 500 },
    );
  }
}

req.formData() reads the submitted form data from the request

Convert File

app/api/upload/route.ts
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { type NextRequest, NextResponse } from "next/server";
import { s3Client } from "@/lib/spaces";

export async function POST(req: NextRequest) {
  const contentType = req.headers.get("content-type");
  if (!contentType || !contentType.includes("multipart/form-data")) {
    return NextResponse.json(
      { error: "Invalid content type" },
      { status: 400 },
    );
  }

  try {
    const formData = await req.formData();
    const file = formData.get("file") as File | null;

    if (!file) {
      return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
    }

    const fileBuffer = Buffer.from(await file.arrayBuffer());
    const key = `uploads/${Date.now()}-${file.name}`;

    const command = new PutObjectCommand({
      Bucket: process.env.SPACES_BUCKET, // next-app-files
      Key: key,
      Body: fileBuffer,
      ACL: "public-read",
      ContentType: file.type || "application/octet-stream",
    });

    await s3Client.send(command);

    const url = `https://${process.env.SPACES_BUCKET}.${process.env.SPACES_REGION}.digitaloceanspaces.com/${key}`;

    return NextResponse.json({ url });
  } catch (error) {
    console.error("Upload error:", error);
    return NextResponse.json(
      { error: "Failed to upload file" },
      { status: 500 },
    );
  }
}
  • Convert the uploaded file into a binary buffer (required for AWS SDK)
  • Generate a unique file name to avoid filename conflicts

Create and Send Command

app/api/upload/route.ts
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { type NextRequest, NextResponse } from "next/server";
import { s3Client } from "@/lib/spaces";

export async function POST(req: NextRequest) {
  const contentType = req.headers.get("content-type");
  if (!contentType || !contentType.includes("multipart/form-data")) {
    return NextResponse.json(
      { error: "Invalid content type" },
      { status: 400 },
    );
  }

  try {
    const formData = await req.formData();
    const file = formData.get("file") as File | null;

    if (!file) {
      return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
    }

    const fileBuffer = Buffer.from(await file.arrayBuffer());
    const key = `uploads/${Date.now()}-${file.name}`;

    const command = new PutObjectCommand({
      Bucket: process.env.SPACES_BUCKET, // next-app-files
      Key: key,
      Body: fileBuffer,
      ACL: "public-read",
      ContentType: file.type || "application/octet-stream",
    });

    await s3Client.send(command);

    const url = `https://${process.env.SPACES_BUCKET}.${process.env.SPACES_REGION}.digitaloceanspaces.com/${key}`;

    return NextResponse.json({ url });
  } catch (error) {
    console.error("Upload error:", error);
    return NextResponse.json(
      { error: "Failed to upload file" },
      { status: 500 },
    );
  }
}
  • DigitalOcean Spaces supports private (full control to the bucket owner, block public access) and public-read (allow unauthenticated read access to anyone)

Route Handler

app/api/upload/route.ts
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { type NextRequest, NextResponse } from "next/server";
import { s3Client } from "@/lib/spaces";

export async function POST(req: NextRequest) {
  const contentType = req.headers.get("content-type");
  if (!contentType || !contentType.includes("multipart/form-data")) {
    return NextResponse.json(
      { error: "Invalid content type" },
      { status: 400 },
    );
  }

  try {
    const formData = await req.formData();
    const file = formData.get("file") as File | null;

    if (!file) {
      return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
    }

    const fileBuffer = Buffer.from(await file.arrayBuffer());
    const key = `uploads/${Date.now()}-${file.name}`;

    const command = new PutObjectCommand({
      Bucket: process.env.SPACES_BUCKET, // next-app-files
      Key: key,
      Body: fileBuffer,
      ACL: "public-read",
      ContentType: file.type || "application/octet-stream",
    });

    await s3Client.send(command);

    const url = `https://${process.env.SPACES_BUCKET}.${process.env.SPACES_REGION}.digitaloceanspaces.com/${key}`;

    return NextResponse.json({ url });
  } catch (error) {
    console.error("Upload error:", error);
    return NextResponse.json(
      { error: "Failed to upload file" },
      { status: 500 },
    );
  }
}

Return the uploaded file URL

Upload Page

app/upload/page.tsx
"use client";

import { useState } from "react";

export default function UploadPage() {
  const [file, setFile] = useState<File | null>(null);
  const [url, setUrl] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.SubmitEvent) {
    e.preventDefault();

    if (!file) {
      setError("Please select a file");
      return;
    }

    const formData = new FormData();
    formData.append("file", file);

    try {
      const res = await fetch("/api/upload", {
        method: "POST",
        body: formData,
      });

      if (!res.ok) {
        throw new Error("Upload failed");
      }

      const data: { url: string } = await res.json();
      setUrl(data.url);
      setError(null);
    } catch (err) {
      setError("Failed to upload file");
      console.error(err);
    }
  }

  return (
    <>
      <h1>Upload to DigitalOcean Spaces</h1>

      <form onSubmit={handleSubmit}>
        <input
          type="file"
          onChange={(e) => setFile(e.target.files?.[0] || null)}
        />
        <button type="submit">Upload</button>
      </form>

      {error && <p>{error}</p>}
      {url && <p>File uploaded: {url}</p>}
    </>
  );
}

Upload Form

  <form onSubmit={handleSubmit}>
    <input
      type="file"
      onChange={(e) => setFile(e.target.files?.[0] || null)}
    />
    <button type="submit">Upload</button>
  </form>

<input type="file"> element has a special property called files

  • files is a FileList
  • It may be empty
  • We need to safely read the first file

Optional Chaining

e.target.files?.[0]`

?. allows to safely access nested properties

  • If e.target.files is a valid FileList, it will access the first file (files[0])
  • If e.target.files is null or undefined, it will stop the evaluation and return undefined, rather than throwing an error

Upload Page

app/upload/page.tsx
"use client";

import { useState } from "react";

export default function UploadPage() {
  const [file, setFile] = useState<File | null>(null);
  const [url, setUrl] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.SubmitEvent) {
    e.preventDefault();

    if (!file) {
      setError("Please select a file");
      return;
    }

    const formData = new FormData();
    formData.append("file", file);

    try {
      const res = await fetch("/api/upload", {
        method: "POST",
        body: formData,
      });

      if (!res.ok) {
        throw new Error("Upload failed");
      }

      const data: { url: string } = await res.json();
      setUrl(data.url);
      setError(null);
    } catch (err) {
      setError("Failed to upload file");
      console.error(err);
    }
  }

  return (
    <>
      <h1>Upload to DigitalOcean Spaces</h1>

      <form onSubmit={handleSubmit}>
        <input
          type="file"
          onChange={(e) => setFile(e.target.files?.[0] || null)}
        />
        <button type="submit">Upload</button>
      </form>

      {error && <p>{error}</p>}
      {url && <p>File uploaded: {url}</p>}
    </>
  );
}

Upload Flow

Select a file
  ↓
Create FormData
  ↓
POST to /api/upload
  ↓
Route Handler uploads to Spaces
  ↓
Return JSON with the file URL
  ↓
Show result in the page

One Improvement

{url && <p>File uploaded: {url}</p>}

The URL is displayed, but it is not clickable

{url && (
  <p>
    File uploaded: <a href={url}>{url}</a>
  </p>
)}

Should we use Next.js <Link> instead?

Upload Page

app/upload/page.tsx
"use client";

import { useState } from "react";

export default function UploadPage() {
  const [file, setFile] = useState<File | null>(null);
  const [url, setUrl] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.SubmitEvent) {
    e.preventDefault();

    if (!file) {
      setError("Please select a file");
      return;
    }

    const formData = new FormData();
    formData.append("file", file);

    try {
      const res = await fetch("/api/upload", {
        method: "POST",
        body: formData,
      });

      if (!res.ok) {
        throw new Error("Upload failed");
      }

      const data: { url: string } = await res.json();
      setUrl(data.url);
      setError(null);
    } catch (err) {
      setError("Failed to upload file");
      console.error(err);
    }
  }

  return (
    <>
      <h1>Upload to DigitalOcean Spaces</h1>

      <form onSubmit={handleSubmit}>
        <input
          type="file"
          onChange={(e) => setFile(e.target.files?.[0] || null)}
        />
        <button type="submit">Upload</button>
      </form>

      {error && <p>{error}</p>}
      {url && (
        <p>
          File uploaded:{" "}
          <a href={url} target="_blank" rel="noopener noreferrer">
            {url}
          </a>
        </p>
      )}
    </>
  );
}

Live Demo

app/upload/page.tsx
"use client";

import { useState } from "react";

export default function UploadPage() {
  const [file, setFile] = useState<File | null>(null);
  const [url, setUrl] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.SubmitEvent) {
    e.preventDefault();

    if (!file) {
      setError("Please select a file");
      return;
    }

    const formData = new FormData();
    formData.append("file", file);

    try {
      const res = await fetch("/api/upload", {
        method: "POST",
        body: formData,
      });

      if (!res.ok) {
        throw new Error("Upload failed");
      }

      const data: { url: string } = await res.json();
      setUrl(data.url);
      setError(null);
    } catch (err) {
      setError("Failed to upload file");
      console.error(err);
    }
  }

  return (
    <main className="p-6">
      <h1 className="text-4xl font-bold tracking-tight mb-8">
        Upload to DigitalOcean Spaces
      </h1>

      <form onSubmit={handleSubmit} className="space-y-6">
        <input
          type="file"
          onChange={(e) => setFile(e.target.files?.[0] || null)}
          className="block w-full text-sm text-gray-600
          file:mr-4 file:rounded-md file:border-0
          file:bg-gray-900 file:px-4 file:py-2
          file:text-sm file:font-medium file:text-white
          hover:file:bg-gray-700"
        />

        <button
          type="submit"
          className="inline-flex items-center rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-gray-700"
        >
          Upload
        </button>
      </form>

      {error && (
        <p className="mt-6 text-sm font-medium text-red-600">{error}</p>
      )}

      {url && (
        <p className="mt-6 text-sm text-gray-700">
          File uploaded:{" "}
          <a
            href={url}
            target="_blank"
            rel="noopener noreferrer"
            className="font-medium text-blue-600 hover:text-blue-800 hover:underline break-all"
          >
            {url}
          </a>
        </p>
      )}
    </main>
  );
}

Store File Metadata

In a real application, uploading the file is only part of the workflow

We often also want to store metadata in relational database for organizing and querying information

  • File URL
  • Object key
  • Original filename
  • Which user uploaded it

Typical Pattern

Object Storage: Stores the file

Relational Database: Stores information about the file

Store File Metadata

Example with Prisma ORM

await prisma.attachment.create({
  data: {
    fileName: file.name,
    fileUrl: url,
    storageKey: key,
    userId,
  },
});

Recap: Cloud Storage

Store data on remote servers (scalability, reliability, accessibility)

  • Object Storage

    • Designed for storing large files (images, PDFs, videos)
    • Files are stored as objects identified by a unique key
  • DigitalOcean Spaces

    • S3-compatible object storage
    • Built-in CDN for faster delivery of files

Recap: Cloud Storage

Upload Flow in Next.js

  1. User selects a file in the browser
  2. File is sent to a Route Handler (/api/upload)
  3. Server uploads the file to DigitalOcean Spaces
  4. Server returns the file URL

Store Metadata in Database

  • Object storage stores the file
  • Database stores metadata (URL, filename, user, related record)

Today’s Lecture

Cloud Storage

User Authentication

User Authentication

Authentication: Who is the user?

  • User must prove their identity (e.g. username and password)

Authorization: What is the user allowed to do?

  • Decides what routes or resources a user can access
  • Examples
    • Only logged-in users can create posts
    • Users can only edit their own content
    • Admin users may have additional privileges

Why Authentication Matters?

Authentication is a core component of modern web applications

  • Protects sensitive data and functionality
  • Prevents unauthorized access
  • Allows applications to personalize user experiences

Typical Authentication Flow

User submits login form
      ↓
Server verifies credentials
      ↓
Server creates a session or token
      ↓
Browser stores session cookie or token
      ↓
Future requests include authentication
  • Token: A piece of data that represents a user’s identity or session state in a secure and verifiable way

Authentication in Next.js

A typical authentication system includes:

  • User database (stores user accounts)
  • Login and registration pages
  • Authentication API endpoints
  • Session management
  • Protected routes

Implementing this from scratch can be complex

Authentication frameworks can simplify the process

Better Auth

A framework-agnostic authentication and authorization framework for TypeScript launched in 2024

  • TypeScript-first: Typed users, APIs, sessions
  • Supports popular frameworks: React, Next.js
  • Integrates with databases: SQLite, PostgreSQL, MySQL
  • Built-in support for sessions and social login
  • Lightweight and extensible

Setup

Install Better Auth

npm install better-auth

Install Prisma and related packages

npm install prisma @types/pg --save-dev
npm install @prisma/client @prisma/adapter-pg pg dotenv

Initialize Prisma ORM

npx prisma init --datasource-provider postgresql --output ../generated/prisma

Set Environment Variables

.env
BETTER_AUTH_SECRET=44C1P97Sia4TzF41wmbdYQsbcwTYXi0d
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL="postgresql://your-os-username@localhost:5432/auth-example?schema=public"
  • BETTER_AUTH_SECRET: A random secret used by Better Auth for encryption and generating hashes
  • BETTER_AUTH_URL: Base URL of your application

Create A Better Auth Instance

Create lib/auth.ts to config Better Auth

lib/auth.ts
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  //...
});

Configure Database

lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "@/lib/prisma";

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),
});

This adapter allows Better Auth to store users, sessions, authentication data in a PostgreSQL database

Create Database Tables

Generate the database schema required by Better Auth

npx auth@latest generate
  • Add four data models to prisma/schema.prisma

Migrate

npx prisma migrate dev --name init

Generate Prisma Client

npx prisma generate

Authentication Methods

Better Auth supports multiple authentication methods

lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "@/lib/prisma";

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),

  emailAndPassword: {
    enabled: true,
  },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
    },
  },
});

Mount Handler

Create a route handler to process authentication requests

app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth"; // path to your auth file

export const { POST, GET } = toNextJsHandler(auth);

Catch-All Route

Dynamic routes: app/posts/[id]/page.tsx

  • [id]: Captures just one URL segment (Single-segment dynamic route)
    • /posts/123/comments requires a separate route [id]/comments

[...all]: Captures all remaining segments of the URL path after the base path /api/auth (Catch-all route)

  • api/auth/[...all] matches /api/auth/sign-in, /api/auth/sign-out, /api/auth/github/callback

Mount Handler

Create a route handler to process authentication requests

app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth"; // path to your auth file

export const { POST, GET } = toNextJsHandler(auth);

One route handles many authentication endpoints

Create Client Instance

Client-side library helps you interact with the auth server

Auth Server

The server hosting authentication API endpoints (e.g., /api/auth/sign-in)

Better Auth supports two deployment models:

  • Same Domain: Auth API and your Next.js app run on the same server
  • Different Domain: Auth API runs as a separate service
    • Production: Deploy the auth API to a dedicated server

Create Client Instance

Client-side library helps you interact with the auth server

lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
  /** The base URL of the server (optional if you're using the same domain) */
  baseURL: "http://localhost:3000",
});

Basic Usage: Sign Up

Better Auth provides helper functions for authentication

Use signUpEmail

Server Actions

Authentication logic should run on the server

Server Actions provide a secure way to handle this

lib/auth-actions.ts
"use server";

import { auth } from "@/lib/auth";

export async function signUpWithEmail(formData: FormData) {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  const name = formData.get("name") as string;

  try {
    const data = await auth.api.signUpEmail({
      body: {
        email,
        password,
        name,
      },
    });

    console.log("Sign-up response:", data);

    return {
      success: true,
      message: "Sign-up successful!",
    };
  } catch (error) {
    console.error("Sign-up error:", error);

    return {
      success: false,
      message: error instanceof Error ? error.message : "Sign-up failed",
    };
  }
}

Sign-Up Page

app/(auth)/signup/page.tsx
"use client";

import { useState } from "react";
import { signUpWithEmail } from "@/lib/auth-actions";

export default function SignUpPage() {
  const [message, setMessage] = useState("");

  async function handleSignUp(formData: FormData) {
    const result = await signUpWithEmail(formData);
    setMessage(result.message);
  }

  return (
    <>
      <h1>Sign Up</h1>
      <form action={handleSignUp}>
        <label htmlFor="email">Email:</label>
        <input type="email" name="email" required />
        <label htmlFor="password">Password:</label>
        <input type="password" name="password" required />
        <label htmlFor="name">Name:</label>
        <input type="text" name="name" required />
        <button type="submit">Sign Up</button>
      </form>
      {message && <p>{message}</p>}
    </>
  );
}

Route Group

app/(auth)/signup/page.tsx

(auth): Keep auth-related pages (e.g., /signup, /signin) together in the file system without affecting the URL

  • The URL is /signup, not /auth/signup
  • Scale well if you add more auth pages (e.g., /signin, /profile) under (auth)

Route Group: Caution

Routes inside different route groups cannot resolve to the same URL

(marketing)/about/page.tsx & (shop)/about/page.tsx?

  • Both resolve to /about
  • Cause a route conflict error

Sign-Up Page

app/(auth)/signup/page.tsx
"use client";

import { useState } from "react";
import { signUpWithEmail } from "@/lib/auth-actions";

export default function SignUpPage() {
  const [message, setMessage] = useState("");

  async function handleSignUp(formData: FormData) {
    const result = await signUpWithEmail(formData);
    setMessage(result.message);
  }

  return (
    <>
      <h1>Sign Up</h1>
      <form action={handleSignUp}>
        <label htmlFor="email">Email:</label>
        <input type="email" name="email" required />
        <label htmlFor="password">Password:</label>
        <input type="password" name="password" required />
        <label htmlFor="name">Name:</label>
        <input type="text" name="name" required />
        <button type="submit">Sign Up</button>
      </form>
      {message && <p>{message}</p>}
    </>
  );
}

Live Demo

app/(auth)/signup/page.tsx
"use client";

import { useState } from "react";
import { signUpWithEmail } from "@/lib/auth-actions";

export default function SignUpPage() {
  const [message, setMessage] = useState("");

  async function handleSignUp(formData: FormData) {
    const result = await signUpWithEmail(formData);
    setMessage(result.message);
  }

  return (
    <main className="max-w-3xl mx-auto px-4 py-10 space-y-6">
      <h1 className="text-5xl font-bold mb-8">Sign Up</h1>
      <form action={handleSignUp} className="flex flex-col space-y-4">
        <label htmlFor="email">Email:</label>
        <input type="email" name="email" className="border p-2" required />
        <label htmlFor="password">Password:</label>
        <input
          type="password"
          name="password"
          className="border p-2"
          required
        />
        <label htmlFor="name">Name:</label>
        <input type="text" name="name" className="border p-2" required />
        <button type="submit" className="bg-blue-500 text-white p-2 rounded">
          Sign Up
        </button>
      </form>
      {message && <p className="mt-4">{message}</p>}
    </main>
  );
}

Recap: Authentication

  • Authentication verifies a user’s identity
  • Authorization controls what users can access
  • Better Auth: A framework-agnostic authentication and authorization framework for TypeScript

Course Wrap-Up

  • HTML & CSS & JavaScript & TypeScript
  • Express.js
  • SQLite & PostgreSQL & Prisma ORM
  • React
  • Next.js
  • Tailwind CSS & shadcn/ui
  • Cloud Storage
  • User Authentication

Project Timeline

Milestone Due Date
Project Introducion March 18
Presentation Slides March 19
Presentation March 20 & 27
Final Project Deliverable April 3

Common Questions

Presentation

Lecture 10 (March 20) Presentation Slots

Lecture 11 (March 27) Presentation Slots

Rubric

Presentation Rubric

  • Clarity of Presentation (0-6 Points)
  • Core Technical Requirements (0-10 Points)
  • Advanced Features and Future Plan (0-4 Points)

Logistics

Presentation Logistics

  • Arrive by 10:30; class starts at 10:40
  • Next three teams sit in the front row
  • 6 minutes presenting, strictly enforced
    • Hand signal at 1 minute remaining

Logistics

  • One laptop, prepared in advance
    • Classroom supports USB-C and HDMI
    • Bring your own adapter if needed
  • Live demo preferred
  • No Q&A and no interruptions
    • If something is unclear, reflect in your peer evaluation
  • All members attend both days for peer review

Optional Practice & Feedback Session

Time: 3:00 PM to 5:00 PM, March 19, 2026

Location: Room 106, Health Sciences Building (HS)

  • Jointly organized with ECE1779
  • Intended for students who would like extra practice or feedback, regardless of current confidence level

Optional Practice & Feedback Session

  • Prof. Coll will share more detailed presentation strategies
  • Teams may deliver a 6-minute dry run (or a portion of their presentation)
    • If your team would like to do a live dry run, please complete this form
  • Prof. Coll and the instructor will provide feedback and suggestions

Optional Practice & Feedback Session

Time: 3:00 PM to 5:00 PM, March 19, 2026

Location: Room 106, Health Sciences Building (HS)

  • Entirely optional
  • Not graded
  • You are welcome to participate in whatever way feels most useful to you
  • More details are on the course website