React Ecosystem and Modern Frameworks
Chen Ying
Assistant Professor, Teaching Stream
Department of Electrical and Computer Engineering
University of Toronto
Navigation in Next.js
Link: Preferred for page navigationuseRouter: Programmatic navigation in Client ComponentsusePathname: Read the current URLApp Router Conventions
page.tsx: Page contentlayout.tsx: Shared layoutroute.ts: API endpointRoute Handlers + Prisma ORM
app/api/Server Components: Can fetch data directly without useEffect
await pauses rendering of a Server Component<Suspense>: Defines a loading boundary
Server Actions: Asynchronous functions that run on the server
"use server"Improve user experience with React hooks
useState, useTransition, useRouterA web development strategy that starts with a functional baseline
Baseline Version (Server Component) can work even before client-side JavaScript finishes loading
<form>)Enhanced Version: JavaScript enhances UX
useTransition: Diable button during submissionuseState: Display success/error messageuseRouter: Redirect after delayProgressive enhancement improves
Backend: API Route (app/api/posts/route.ts)
“Frontend”: A Server Component (app/posts/page.tsx)
fetch from /api/postsdynamic = "force-dynamic" for runtime data retrievalServer Components generate UI of the page
Server Components are not purely frontend code
In Next.js, the boundary between “frontend” and “backend” becomes less obvious
Instead of thinking frontend vs. backend, think in layers:
Think Server Components as the rendering layer
Browser
↑
HTML from Server Component
↑
Server Component
↑
API Route
↑
Database
Server Components fetch data, generate HTML, and send the result to the browser
Cloud Storage
Many applications need to store files
Should files go in PostgreSQL/SQLite?
Database: Structured records
Object Storage: Large files
A common pattern:
Storing data on remote servers instead of local devices
Benefits
A network of servers that cache and deliver content closer to users
User requests a file → Routed to the nearest CDN edge server
Benefits
Without CDN
With CDN
Especially useful for public assets such as images and downloadable files
S3-compatible object storage service by DigitalOcean
A standardized way to store and retrieve files (“objects”) over the network
Why S3 Matters
S3-compatible object storage service by DigitalOcean
Free Credit: Sign up for the GitHub Student Developer Pack for $200 of DigitalOcean credit
If you see a charge, contact Digital Support Team
$5/month for 250GB storage, 1TB bandwidth
A collection of libraries that allow developers to interact with AWS services programmatically
.env.env
SPACES_ENDPOINT is the regional endpoint, not the bucket-specific URL
DigitalOcean uese the regional endpoint pattern ${REGION}.digitaloceanspaces.com
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!,
},
});! OperatorNon-null assertion operator
null or undefined”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!,
},
});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 },
);
}
}File uploads are usually sent as multipart/form-data
Ensures that the request is using multipart/form-data encoding
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
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 },
);
}
}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 },
);
}
}private (full control to the bucket owner, block public access) and public-read (allow unauthenticated read access to anyone)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
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>}
</>
);
}<input type="file"> element has a special property called files
files is a FileList?. allows to safely access nested properties
e.target.files is a valid FileList, it will access the first file (files[0])e.target.files is null or undefined, it will stop the evaluation and return undefined, rather than throwing an errorapp/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>}
</>
);
}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
The URL is displayed, but it is not clickable
Should we use Next.js <Link> instead?
<Link>Enables client-side navigation between routes in the same application
<a> Linktarget="_blank": Opens the link in a new tabrel="noopener noreferrer": Improve security and privacy for new-tab links
noopener: Prevents the new page from accessing window.opener (can be used to access the original page)noreferrer: Hides the referring page’s URL<Link> vs. <a>Use <Link> for internal navigation
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>
)}
</>
);
}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>
);
}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
Object Storage: Stores the file
Relational Database: Stores information about the file
Example with Prisma ORM
Store data on remote servers (scalability, reliability, accessibility)
Object Storage
DigitalOcean Spaces
Upload Flow in Next.js
/api/upload)Store Metadata in Database
Cloud Storage
User Authentication
Authentication: Who is the user?
Authorization: What is the user allowed to do?
Authentication is a core component of modern web applications
User submits login form
↓
Server verifies credentials
↓
Server creates a session or token
↓
Browser stores session cookie or token
↓
Future requests include authentication
A typical authentication system includes:
Implementing this from scratch can be complex
Authentication frameworks can simplify the process
A framework-agnostic authentication and authorization framework for TypeScript launched in 2024
Install Better Auth
Install Prisma and related packages
.env
BETTER_AUTH_SECRET: A random secret used by Better Auth for encryption and generating hashes
BETTER_AUTH_URL: Base URL of your applicationCreate lib/auth.ts to config Better Auth
lib/auth.ts
This adapter allows Better Auth to store users, sessions, authentication data in a PostgreSQL database
Generate the database schema required by Better Auth
prisma/schema.prismaBetter 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,
},
},
});Create a route handler to process authentication requests
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…Create a route handler to process authentication requests
app/api/auth/[...all]/route.ts
One route handles many authentication endpoints
Client-side library helps you interact with the auth server
The server hosting authentication API endpoints (e.g., /api/auth/sign-in)
Better Auth supports two deployment models:
Client-side library helps you interact with the auth server
Better Auth provides helper functions for authentication
Use signUpEmail
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",
};
}
}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>}
</>
);
}app/(auth)/signup/page.tsx
(auth): Keep auth-related pages (e.g., /signup, /signin) together in the file system without affecting the URL
/signup, not /auth/signup/signin, /profile) under (auth)Routes inside different route groups cannot resolve to the same URL
(marketing)/about/page.tsx & (shop)/about/page.tsx?
/aboutapp/(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>}
</>
);
}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>
);
}| Milestone | Due Date |
|---|---|
| Project Introducion | March 18 |
| Presentation Slides | March 19 |
| Presentation | March 20 & 27 |
| Final Project Deliverable | April 3 |
Time: 3:00 PM to 5:00 PM, March 19, 2026
Location: Room 106, Health Sciences Building (HS)
Time: 3:00 PM to 5:00 PM, March 19, 2026
Location: Room 106, Health Sciences Building (HS)