React is the most popular frontend library in the world. In the 2025 Stack Overflow Developer Survey, 39.5% of professional developers use it — more than any other frontend framework, for years in a row. It was built by Meta in 2013 around one idea: describe what the UI should look like for a given state, and let React handle the updates.
This guide takes you from your first component to a deployed, full-stack app. You will build TaskFlow, a task manager with GitHub sign-in, a PostgreSQL database, and server-rendered pages, using React 19, TypeScript, and Next.js 15. Every section adds a concept. The final part connects them all.
What you will have by the end: a solid grip on hooks, TypeScript with React, and component patterns; comfort with routing, forms, and server data fetching; and a production-shaped full-stack app — auth, database, server-rendered pages, and deployment — built with the tools real teams use in 2026.
Some JavaScript or TypeScript background helps, but you do not need prior React experience. Full source code for the capstone project is on GitHub: github.com/kemalcodes/react-tutorial.
Part 1: React Foundations
Why React, and React vs the Alternatives
React is a library, not a framework — it handles the view layer, and you combine it with other tools for routing, data fetching, and styling. Two ideas hold it together: components (UI built from small, reusable pieces) and declarative rendering (you describe the UI as a function of your data; React figures out what changed and updates only that part of the real DOM).
function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>;
}
React keeps a Virtual DOM — a lightweight in-memory copy of the real DOM. When data changes, it builds a new virtual tree, diffs it against the old one, and patches only what changed. This process is called reconciliation, and it is why React stays fast even as UIs grow complex.
| React | Angular | Vue | |
|---|---|---|---|
| Type | Library | Full framework | Progressive framework |
| Language | JS/TypeScript | TypeScript (required) | JS/TypeScript |
| Learning curve | Medium | Steep | Easy |
| Bundle size | Small (library only) | Large | Medium |
React wins on job-market demand, which is why this guide uses it. For production apps, react.dev now recommends building on a framework rather than raw React — Next.js is their top pick. We use plain React with Vite for Parts 1–3 (the concepts transfer everywhere), then move to Next.js for Part 4.
React 19 (December 2024) is the biggest release in years. Three additions matter for this guide: Actions (useActionState) remove the manual loading/error state boilerplate around async operations; use() reads a Promise or Context directly during render and integrates with Suspense; useOptimistic shows a result before the server confirms it, for instant-feeling UI.
Project Setup
create-react-app is dead — do not use it. The 2026 standard is Vite:
npm create vite@latest myapp -- --template react-ts
cd myapp && npm install && npm run dev
Vite generates src/main.tsx (the entry point, which mounts <App /> into #root via createRoot) and src/App.tsx (your root component). Files with JSX use .tsx; files without it use .ts. Vite’s Hot Module Replacement updates the browser on save — no manual refresh.
JSX Rules
JSX looks like HTML but compiles to React.createElement() calls — you never write those calls yourself. Five rules trip up newcomers:
- One root element. Wrap siblings in a
<div>or a Fragment (<>...</>) if you don’t want an extra DOM node. - Self-close every empty tag —
<img />, not<img>. - camelCase attributes:
classNamenotclass,htmlFornotfor,onClicknotonclick,tabIndexnottabindex. - Close every tag.
{}embeds any JS expression — variables, calculations, function calls, ternaries.styletakes an object:style={{ color: "red" }}, not a string.
A sharp edge with &&: {count && <p>{count} items</p>} renders the literal 0 when count is 0, because 0 is falsy but React still renders it as text. Use a boolean comparison: {count > 0 && <p>...</p>}.
Props, State, and Events
Props flow one way, parent to child, and are read-only. State is data a component owns and can change, via useState:
interface CardProps {
title: string;
description?: string; // optional — defaults handled by destructuring
}
function Card({ title, description = "No description" }: CardProps) {
return <div><h2>{title}</h2><p>{description}</p></div>;
}
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
Never mutate state directly — always create a new value: setUser({ ...user, name: "Alex" }), not user.name = "Alex". When the new value depends on the old one, use the updater function form, especially before quick repeated calls or async code: setCount(prev => prev + 1) — three calls of setCount(count + 1) in one handler all read the same stale count and only increment once; three calls of setCount(prev => prev + 1) correctly increment three times.
Event handlers pass the function reference, never call it: onClick={handleClick}, not onClick={handleClick()} (which fires immediately on render). TypeScript gives each event a specific type — React.MouseEvent<HTMLButtonElement>, React.ChangeEvent<HTMLInputElement>, React.FormEvent<HTMLFormElement>, React.KeyboardEvent<HTMLInputElement> — the generic parameter is the target element. event.preventDefault() stops default browser behavior (like a form’s page reload on submit); event.stopPropagation() stops an event bubbling to a parent handler.
Conditional Rendering and Lists
Use plain if when a whole component renders differently, a ternary inside JSX for a two-way choice, and && to show something only when true (with the falsy-zero gotcha above in mind). ?? falls back only for null/undefined, unlike || which also catches "" and 0.
Render arrays with .map(), and give each item a stable, unique key:
{users.map(user => <li key={user.id}>{user.name}</li>)}
Never use the array index as a key for a list that can reorder or have items added/removed from the middle. React uses the key to decide what to patch — if the index shifts, React thinks every item after the change is a different item, causing wrong re-renders and, notoriously, input state landing on the wrong row. Index keys are fine only for static lists that never change.
Part 2: Hooks and State Patterns
useState and useEffect In Depth
Pass a function to useState for an expensive initial value — useState(() => computeExpensive()) runs it once, on mount; useState(computeExpensive()) runs it on every render. In React 18+, state updates inside event handlers and async code are batched automatically — several setState calls in one handler cause exactly one re-render.
useEffect runs side effects — code outside the render cycle. The dependency array controls timing: omit it to run after every render (rarely correct), pass [] to run once on mount, or list values to re-run when any of them change.
The correct data-fetching shape guards against a race condition: if userId changes before the first fetch resolves, an unguarded effect can overwrite fresh state with a stale response.
useEffect(() => {
let cancelled = false;
async function fetchUser() {
setLoading(true);
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error("Failed to fetch");
const data = await res.json();
if (!cancelled) setUser(data);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Unknown error");
} finally {
if (!cancelled) setLoading(false);
}
}
fetchUser();
return () => { cancelled = true; }; // cleanup: ignore a late response
}, [userId]);
Every effect with a subscription, timer, or listener needs matching cleanup in the returned function: clearInterval, clearTimeout, removeEventListener. Skipping it leaks memory and duplicates listeners on every re-run.
Hooks follow two non-negotiable rules: call them only at the top level (never inside if, loops, or nested functions — put the condition inside the effect instead), and only from component functions or other hooks.
The React team’s own guidance flags common unnecessary useEffect uses: deriving a value from props/state should be a plain variable (const fullName = \${first} ${last}`), not state-plus-effect; resetting state when a prop changes should use key={userId}on the component so React remounts it, not an effect that manually resets each field. For data fetching in production apps, prefer [TanStack Query](#data-fetching-with-tanstack-query) over hand-rolleduseEffect` fetches — it adds caching and deduplication for free.
React 19’s use() hook reads a Promise directly during render and suspends until it resolves, working with <Suspense> instead of loading/error state:
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // suspends until resolved
return <h2>{user.name}</h2>;
}
// <Suspense fallback={<p>Loading...</p>}><UserProfile userPromise={fetchUser("1")} /></Suspense>
Custom Hooks
A custom hook is a function starting with use that calls other hooks and extracts reusable stateful logic — no render props, no higher-order components, just a function. Create one when two or more components share the same stateful logic, or a component’s logic is complex enough to test separately. Don’t extract one just to extract — a hook used in one place with simple logic can stay inline.
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue; // SSR safety
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch { return initialValue; }
});
function setValue(value: T | ((prev: T) => T)) {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
return [storedValue, setValue] as const;
}
useDebounce is the other hook worth memorizing — it delays a value update until the user stops typing, cutting unnecessary API calls on every keystroke:
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
Return an array (like useState) when callers should rename the values on destructure; return an object when there are several named values and order shouldn’t matter (like useFetch’s { data, loading, error }).
useRef, useMemo, useCallback
useRef creates a mutable container that persists across renders — changing .current does not trigger a re-render. Its main uses: reaching a DOM node (<input ref={inputRef} />, then inputRef.current?.focus()), and storing a value you need to track (a timer ID, a previous value) without causing extra renders.
useMemo caches an expensive computation between renders, recomputing only when its dependencies change. useCallback caches a function reference the same way. Both exist for one reason: every render recreates every object and function inside a component, and if you hand a fresh reference to a React.memo-wrapped child every time, that child re-renders anyway — defeating the memoization.
const Button = memo(function Button({ onClick, label }: { onClick: () => void; label: string }) {
return <button onClick={onClick}>{label}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);
const handleIncrement = useCallback(() => setCount(c => c + 1), []); // stable reference
return (
<>
<Button onClick={handleIncrement} label="Increment" /> {/* skips re-render when `other` changes */}
<button onClick={() => setOther(o => o + 1)}>Change Other</button>
</>
);
}
Do not memoize by default. Premature useMemo/useCallback adds complexity and its own overhead, and can hide bugs behind wrong dependency arrays. Profile with React DevTools first; memoize only what the profiler actually flags as slow.
Context API
Context solves prop drilling — passing data through layers of components that don’t use it, just to reach one that does. Four steps: createContext, wrap a Provider around the tree that needs it, expose a custom hook that throws if used outside the provider (so the error points at the real mistake), and consume it anywhere inside.
const ThemeContext = createContext<{ theme: "light" | "dark"; toggleTheme: () => void } | null>(null);
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = useCallback(() => setTheme(p => p === "light" ? "dark" : "light"), []);
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]); // stable value object
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
return ctx;
}
Memoizing the value object matters: every context consumer re-renders whenever the context value changes, and an object literal { theme, toggleTheme } is a new reference on every render without useMemo. Context suits theme, auth, and app-level preferences well; for frequently-changing state (cursor position, animations) or many independent pieces of state, a library like Zustand avoids the re-render-everything problem — components subscribe only to the slice they read, and it needs no Provider.
Part 3: Real-World Patterns
Routing with React Router v7
React Router v7 (Nov 2024) merged with Remix and now ships two modes: library mode (the classic SPA router, covered here) and framework mode (Remix-style, superseded for this guide by Next.js in Part 4). Install react-router-dom, wrap the app in <BrowserRouter>, and define routes:
<Routes>
<Route element={<Layout />}> {/* parent layout wraps children via <Outlet /> */}
<Route path="/" element={<Home />} />
<Route path="/users/:userId" element={<UserProfile />} /> {/* useParams().userId */}
</Route>
</Routes>
Use <Link>/<NavLink> for internal navigation — never <a href>, which triggers a full page reload and drops React state. useNavigate() navigates from code (navigate("/dashboard"), navigate(-1) for back), useSearchParams() reads/writes ?query=strings.
Gate a group of routes behind auth with a ProtectedRoute wrapper that renders <Outlet /> — the children — only when logged in, and redirects otherwise:
function ProtectedRoute() {
const { user } = useAuth();
if (!user) return <Navigate to="/login" replace />; // replace: no back-button loop
return <Outlet />;
}
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
Forms with React Hook Form and Zod
Plain useState-controlled inputs re-render on every keystroke and require hand-written validation. React Hook Form uses uncontrolled inputs by default (fewer re-renders); Zod describes the validation schema once and both validates and generates the TypeScript type via z.infer:
const loginSchema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type LoginData = z.infer<typeof loginSchema>;
function LoginForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } =
useForm<LoginData>({ resolver: zodResolver(loginSchema) });
return (
<form onSubmit={handleSubmit(async (data) => { await login(data); })}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button disabled={isSubmitting}>{isSubmitting ? "Logging in..." : "Log in"}</button>
</form>
);
}
.refine() adds cross-field validation the base schema can’t express alone — password confirmation matching is the classic case, and path tells React Hook Form which field shows the resulting error:
const signupSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
useFieldArray handles a variable number of repeated fields (multiple phone numbers, line items) — use field.id from the array, not the loop index, as each row’s key. For components that don’t play well with register (date pickers, custom dropdowns), wrap them in <Controller> instead. React 19’s useActionState + form action prop is a lighter alternative for simple forms — React Hook Form + Zod is worth the extra dependency once a form has real client-side validation needs.
Data Fetching with TanStack Query
Hand-rolled useEffect fetching leaves you to build caching, deduplication, retries, and background refetching yourself. TanStack Query (formerly React Query) does all of it:
const { data, isLoading, isError, error } = useQuery({
queryKey: ["posts"], // cache key
queryFn: fetchPosts, // must throw on failure
});
queryKey identifies cached data — changing any value inside it triggers a refetch, which is how ["posts", { userId }] naturally scopes a query per user. staleTime controls how long data is considered fresh (default 0); gcTime controls how long unused data stays in memory (default 5 minutes) — set staleTime higher for data that rarely changes to skip unnecessary refetches. enabled: !!userId runs a query only once a dependency is ready (dependent queries).
useMutation handles writes, and invalidating the affected query in onSuccess is the standard pattern after any create/update/delete — it marks the cache stale so the list refetches automatically instead of going stale silently:
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["posts"] }),
});
// mutation.mutate(data); mutation.isPending / isError / isSuccess drive the UI
useInfiniteQuery covers pagination and infinite scroll, flattening data.pages into one list with .flatMap(). queryClient.prefetchQuery() on hover (a link’s onMouseEnter) warms the cache before the user clicks, making the next page feel instant.
Styling: Tailwind CSS v4 and shadcn/ui
Tailwind v4 (January 2025) dropped the tailwind.config.js file entirely — configuration now lives in CSS via @import "tailwindcss"; and an optional @theme { --color-primary: #3b82f6; } block for custom design tokens. Utility classes are mobile-first (md:flex-row applies at 768px and up) and support dark mode via a dark: prefix, following prefers-color-scheme by default.
shadcn/ui is not a component library you install — you copy components into your project (npx shadcn@latest add button), so you own and can edit the code directly. It’s built on Radix UI primitives, so components come keyboard-accessible and ARIA-correct out of the box. The included cn() helper (clsx + tailwind-merge) merges conditional classes correctly — cn("bg-blue-500", isActive && "bg-red-500", className) keeps only the last conflicting background color instead of applying both.
TypeScript with React
Type a component by typing its props inline — plain function syntax (function Foo(props: Props)) is now preferred over React.FC<Props>, which used to force an implicit children prop (fixed in React 18, so the choice is now purely stylistic; pick one and stay consistent).
useState infers from the initial value, but needs an explicit type when the value doesn’t say enough: useState<User | null>(null), useState<string[]>([]). useRef<HTMLInputElement>(null) types a DOM ref; access it with optional chaining (inputRef.current?.focus()) since it starts null. Type Context with | undefined as the default and throw in the consuming hook if it’s still undefined — this converts a missing-Provider bug into a clear error message instead of a silent crash three components later.
Discriminated unions type useReducer actions correctly — TypeScript narrows the payload inside each case automatically:
type Action = { type: "increment" } | { type: "set_status"; status: State["status"] };
Generic components stay type-safe across data types:
function Select<T>({ options, value, onChange, getLabel, getValue }: SelectProps<T>) { /* ... */ }
And in React 19, ref is a normal prop — no forwardRef wrapper needed to accept one on a custom component.
Testing with Vitest and Testing Library
For Vite projects, use Vitest over Jest — same API, built for Vite, much faster. Install vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom, set environment: "jsdom" in the Vitest config, and test from the user’s perspective: assert on what’s rendered (screen.getByText("Count: 1")), never on internal state.
Query priority: getByRole first (accessible, matches what screen readers see — screen.getByRole("button", { name: "Submit" })), then getByLabelText for form fields, then getByText; data-testid is a last resort. getBy throws if missing, queryBy returns null (use it to assert something is absent), findBy is async for content that appears later. Always drive interactions through @testing-library/user-event’s await user.click(...) / user.type(...) — it simulates real browser event sequences, not just a raw DOM event.
it("increments on click", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
For async data, MSW (Mock Service Worker) intercepts real fetch calls at the network level, which is more realistic than mocking your fetch function directly.
Error Boundaries and Suspense
An Error Boundary catches a rendering error and shows a fallback instead of crashing the whole app — but React only supports this via class components, so use the react-error-boundary library for a functional API:
<ErrorBoundary fallbackRender={({ error, resetErrorBoundary }) => (
<div role="alert">
<p>{error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}>
<UserProfile />
</ErrorBoundary>
Error Boundaries catch render errors only — not errors in event handlers or async code (useEffect, promises). For those, catch manually or call showBoundary(error) from useErrorBoundary() to route the error to the nearest boundary. Place boundaries around independent widgets (a dashboard chart, an orders table) rather than only at the app root, so one broken widget doesn’t take down the rest of the page.
Suspense shows a fallback while something is loading — React.lazy() for code-split components, or the use() hook for a Promise:
const HeavyComponent = lazy(() => import("./HeavyComponent"));
<Suspense fallback={<p>Loading...</p>}><HeavyComponent /></Suspense>
Performance Optimization
Profile before optimizing — install React DevTools, record with the Profiler tab, and only touch components it flags as slow. A component re-renders when its own state changes, its parent re-renders, or a context it reads changes — most of these are fast and not worth chasing.
React.memo skips a re-render when props are shallow-equal (by reference for objects/arrays, by value for primitives). It only helps when paired with useCallback/useMemo upstream, since a fresh inline object or function prop defeats the shallow comparison every time. React.lazy + route-based code splitting shrinks the initial bundle.
For lists with thousands of rows, rendering every item is the bottleneck — @tanstack/react-virtual renders only the rows currently in the viewport, keeping a spacer div for correct scroll height:
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40, // estimated row height in px
});
// virtualizer.getVirtualItems() gives only the rows to actually render, each with a `start` offset
React 19’s experimental React Compiler auto-applies memoization at build time — with it enabled, most manual useMemo/useCallback become unnecessary, though it requires code that already follows React’s rules (pure render functions, no mutation).
Two anti-patterns to catch in review: an inline object or arrow function passed as a prop to a memoized child (<Child style={{ color: "red" }} /> creates a new object every render — hoist it outside or wrap in useMemo), and array index as key on a reorderable list (covered in Part 1).
Part 4: Next.js 15 and Full-Stack
react.dev’s own recommendation for production React is to build on a framework, and Next.js — built by Vercel — is the default choice: file-based routing, Server Components, Server Actions, built-in API routes, and optimized image/font loading, all without extra configuration.
Setup and the App Router
npx create-next-app@latest myapp # choose: TypeScript, Tailwind, src/, App Router, Turbopack
Always choose the App Router over the legacy Pages Router. The folder structure under src/app/ is the route structure — app/blog/[slug]/page.tsx maps to /blog/:slug. Special filenames carry meaning: page.tsx defines a route, layout.tsx wraps pages and persists across navigation, loading.tsx is an automatic Suspense fallback, error.tsx is an automatic Error Boundary (must be a Client Component), and route.ts defines an API endpoint. [param] matches a dynamic segment, [...slug] catches multiple segments, and (groupName) groups routes without affecting the URL.
In Next.js 15, params and searchParams are Promises — you must await them, a change from earlier versions that silently breaks code copied from older tutorials:
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await fetchPost(slug);
return <article><h1>{post.title}</h1></article>;
}
Server Components vs Client Components
Every component in the App Router is a Server Component by default — it runs only on the server, can query a database or read secrets directly, and ships zero JavaScript to the browser for that component. Add "use client" at the top of a file to opt into a Client Component, which is required for useState, useEffect, event handlers, or any browser API.
// Server Component — no directive, runs only on the server
export default async function HomePage() {
const posts = await fetchPostsFromDB(); // direct DB access, no API layer needed
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
A Server Component can render a Client Component as a child (or pass one via children/props) — this is the standard composition pattern, e.g. static article content rendered on the server with an interactive <LikeButton /> client island inside it. The reverse — importing a Server Component directly inside a Client Component file — is not supported. Keep "use client" boundaries as deep in the tree as possible; the more of the tree that stays server-only, the less JavaScript ships to the browser.
Next.js extends fetch() with caching controls: cache: "no-store" for SSR (refetch every request, the effective default in Next.js 15), next: { revalidate: 60 } for ISR (refetch at most every N seconds), cache: "force-cache" to cache until manually revalidated. generateStaticParams() pre-builds dynamic routes at build time for SSG. Wrapping slow, independent sections in their own <Suspense> streams the page — fast sections render immediately while slow ones stream in without blocking the rest.
API Routes and Server Actions
Two ways to run server code: Route Handlers (app/api/.../route.ts, exporting GET/POST/etc.) for a public API consumed by mobile apps or webhooks, and Server Actions ("use server" functions) for mutations from within your own app.
// app/actions.ts
"use server";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
await db.post.create({ data: { title } });
revalidatePath("/blog"); // refresh the cached page
}
A Server Action can be passed directly to a form’s action attribute — the form works even with JavaScript disabled, since it degrades to a normal HTTP POST. useActionState adds pending/error state on top for a Client Component form; useFormStatus, called inside a child of the <form>, reads that same pending state without prop drilling it down. After any mutation, call revalidatePath() or revalidateTag() (matched against fetch(..., { next: { tags: [...] } })) to tell Next.js which cached data to refresh.
Authentication with NextAuth v5
NextAuth.js v5 (Auth.js) centralizes auth config in one src/auth.ts file exporting handlers, signIn, signOut, and auth:
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [GitHub], // v5 auto-reads AUTH_GITHUB_ID / AUTH_GITHUB_SECRET
});
// app/api/auth/[...nextauth]/route.ts
export const { GET, POST } = handlers;
await auth() reads the session anywhere server-side — Server Components, Route Handlers, Server Actions, Middleware; useSession() (wrapped in <SessionProvider>) reads it in Client Components. Wrap signIn/signOut calls in a Server Action bound to a form for progressive enhancement. middleware.ts protects whole route groups in one place instead of checking auth in every page — but it runs on the Edge Runtime, which cannot use most database clients, so a Prisma-backed auth.ts config often needs splitting into an Edge-safe auth.config.ts imported by the middleware. Extend the session shape (like adding user.id) via the session/jwt callbacks, and augment the TypeScript module (declare module "next-auth") to match.
Database with Prisma
Prisma defines your schema in one file, generates a fully typed client, and manages migrations:
model Task {
id Int @id @default(autoincrement())
title String
done Boolean @default(false)
priority Priority @default(MEDIUM)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum Priority { LOW MEDIUM HIGH }
npx prisma migrate dev --name init creates and applies a migration and regenerates the client. Next.js hot-reloads in development, and each reload can spin up a new PrismaClient, exhausting your database’s connection limit — guard against it with a global singleton:
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
Query directly inside async Server Components (await db.task.findMany({ where: { userId } })) — no separate API layer needed for pages that only your own app reads. npx prisma studio opens a visual browser for your data at localhost:5555.
Deployment on Vercel
Vercel (Next.js’s creator) auto-detects the framework on import — push to GitHub, connect the repo, add environment variables, deploy. Every pull request gets an isolated preview URL. Variables prefixed NEXT_PUBLIC_ are exposed to the browser bundle; everything else stays server-only — never commit secrets to Git. Route Handlers and Server Actions run as Vercel Functions (serverless); opting a route into the Edge Runtime (export const runtime = "edge") reduces latency further but drops Node.js APIs like fs, and the standard Prisma client needs an edge-compatible driver (Neon, PlanetScale) to work there. Remember to update your OAuth app’s callback URL to the production domain after deploying, or sign-in will fail with a redirect mismatch.
Part 5: Build TaskFlow — a Full-Stack Task Manager
Everything above is enough to build something real. TaskFlow lets a user sign in with GitHub, then create, complete, and delete their own tasks — nothing here is a disconnected snippet.
Schema — String @id @default(cuid()) instead of an auto-incrementing int, so IDs are safe to expose in URLs without leaking sequence information:
model Task {
id String @id @default(cuid())
title String
done Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
Auth, using PrismaAdapter so NextAuth manages User/Account/Session rows for you:
// src/auth.ts
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(db),
providers: [GitHub],
callbacks: {
async session({ session, user }) {
if (user) session.user.id = user.id;
return session;
},
},
});
Middleware protects every route except /login:
export default auth((req) => {
const isLoggedIn = !!req.auth;
if (!isLoggedIn && req.nextUrl.pathname !== "/login") {
return NextResponse.redirect(new URL("/login", req.url));
}
});
export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"] };
Server Actions validate with Zod and check ownership before every mutation — never trust a task ID from the client without confirming it belongs to the signed-in user:
"use server";
export async function toggleTask(taskId: string) {
const userId = await getCurrentUserId();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.userId !== userId) throw new Error("Task not found");
await db.task.update({ where: { id: taskId }, data: { done: !task.done } });
revalidatePath("/tasks");
}
The tasks page is an async Server Component that queries directly and renders Client Component islands (<TaskItem>, <CreateTaskForm>) for the interactive parts:
export default async function TasksPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const tasks = await db.task.findMany({
where: { userId: session.user.id },
orderBy: [{ done: "asc" }, { createdAt: "desc" }],
});
return (
<div>
<CreateTaskForm />
{tasks.map(task => <TaskItem key={task.id} task={task} />)}
</div>
);
}
TaskItem is a small Client Component calling the Server Actions directly (onClick={handleToggle} → await toggleTask(task.id)), and CreateTaskForm uses useActionState for pending/error state on submit. That’s the whole app: one Server Component queries the database, two Client Components handle interaction, and every write goes through a Server Action that re-checks ownership before touching the row. The same shape scales to any resource — swap Task for Order, Comment, or Document and the pattern holds.
Gotchas That Catch Everyone Once
Stale closures in useEffect/handlers. A function created during one render “closes over” the state values from that render. setCount(count + 1) called three times in one handler reads the same count all three times — use the updater form (setCount(prev => prev + 1)) whenever the new value depends on the old one.
Missing or wrong dependency arrays. An empty [] on an effect that reads a prop means the effect never sees updates to that prop. Include everything the effect reads; if that causes it to fire too often, question whether it needs to be an effect at all (see “when not to use useEffect” in Part 2).
Index as key on a changing list. Covered above — causes wrong item identity, broken input state, and unnecessary re-renders the moment a list reorders or an item is removed from the middle.
Context re-renders every consumer on every value change. An inline value={{ theme, toggleTheme }} object recreates the reference every render, defeating any downstream memoization — wrap it in useMemo.
Server/Client Component boundary mistakes. Importing a Server Component directly inside a "use client" file does not work — pass it as children or a prop instead. Forgetting "use client" on a file that uses useState fails at build time with a clear error; forgetting to await params in a Next.js 15 dynamic route fails silently with undefined values instead.
Hydration mismatches. Rendering something that differs between server and client (typeof window !== "undefined" guarded values, Date.now(), random IDs) produces a console warning and a flash of wrong content — compute such values only after mount, inside useEffect.
Where to Go From Here
- TypeScript Tutorial: From Zero to a Real Project — the type system underpinning everything in this guide
- Go Tutorial: From Zero to a Real Project — a different take on building a typed REST API and CLI
- Kotlin Tutorial: From Zero to a Real Project — another strongly-typed, batteries-included language worth knowing
The complete, working code for TaskFlow is on GitHub: github.com/kemalcodes/react-tutorial.