ASP.NET Core Web API · Quality & Delivery

React + ASP.NET Core API Project

Time to connect the ShopSmart API to a real front end. In this project a React (Vite + TypeScript) app logs in, lists products, creates new ones and shows validation errors that come straight from the API’s problem details.

Advanced 8 min Lesson 36 of 37Examples target .NET 9.
0/37 in ASP.NET Core Web API
On this page · 8 min · Advanced

In simple words#

The React + API project is a shop with a modern shop front (React) and a strong back office (ASP.NET Core API) talking through JSON.

Front end
React app calling the API with fetch.
Back end
ASP.NET Core Web API + SQL Server.
CORS + JWT
Allow the front end and secure the calls.

Learning objectives#

  • Configure the API for a React development origin
  • Write a typed API client with fetch and a bearer token
  • Display server validation errors next to form fields
  • Handle loading, errors and 401 responses

Explanation#

API side: enable CORS for http://localhost:5173 (Vite’s dev server) and the production origin, and return DTOs and problem details as in the earlier lessons. During development you can also use Vite’s proxy and skip CORS.

React side: one module (api.ts) wraps fetch — base URL from import.meta.env.VITE_API_URL, JSON headers, the Authorization header from the stored token, and conversion of problem+json responses into a typed ApiError.

Login: POST /api/auth/login returns an access token; keep it in memory (or sessionStorage) and send it as Authorization: Bearer. On 401, send the user back to the login screen.

Forms: on 400, read problem.errors (field → messages) and show each message under its input — the API’s validation rules appear in the UI without duplicating them.

Every component that loads data handles three states: loading, error and data.

Syntax#

TypeScript Illustrative
const res = await fetch(`${API}/api/products`, { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 400) { const problem = await res.json(); setErrors(problem.errors); }

Code example: Typed API client and a products page#

TSXsrc/ProductsPage.tsx Illustrative
// src/api.ts
const API = import.meta.env.VITE_API_URL ?? 'https://localhost:7046';
let accessToken: string | null = null;


export interface Product { id: number; name: string; price: number }
export type FieldErrors = Record<string, string[]>;


export class ApiError extends Error {
  constructor(public status: number, message: string, public errors: FieldErrors = {}) { super(message); }
}


async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`${API}${path}`, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
      ...init.headers,
    },
  });
  if (res.status === 204) return undefined as T;
  const body = await res.json().catch(() => ({}));
  if (!res.ok) throw new ApiError(res.status, body.title ?? res.statusText, body.errors ?? {});
  return body as T;
}


export async function login(email: string, password: string) {
  const result = await request<{ accessToken: string }>('/api/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
  accessToken = result.accessToken;                       // kept in memory, not localStorage
}
export const getProducts = () => request<Product[]>('/api/products');
export const createProduct = (input: { name: string; price: number }) =>
  request<Product>('/api/products', { method: 'POST', body: JSON.stringify(input) });


// src/ProductsPage.tsx
import { useEffect, useState, type FormEvent } from 'react';
import { ApiError, createProduct, getProducts, type FieldErrors, type Product } from './api';


export default function ProductsPage({ onUnauthorized }: { onUnauthorized: () => void }) {
  const [products, setProducts] = useState<Product[] | null>(null);
  const [loadError, setLoadError] = useState('');
  const [errors, setErrors] = useState<FieldErrors>({});
  const [saving, setSaving] = useState(false);


  useEffect(() => {
    getProducts()
      .then(setProducts)
      .catch((err: unknown) => {
        if (err instanceof ApiError && err.status === 401) onUnauthorized();
        else setLoadError('Could not load products. Please try again.');
      });
  }, [onUnauthorized]);


  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    const formElement = event.currentTarget;
    setSaving(true);
    setErrors({});
    try {
      const created = await createProduct({ name: String(form.get('name')), price: Number(form.get('price')) });
      setProducts(list => [...(list ?? []), created]);
      formElement.reset();
    } catch (err) {
      if (err instanceof ApiError && err.status === 400) setErrors(err.errors);   // server validation → fields
      else if (err instanceof ApiError && err.status === 401) onUnauthorized();
      else setErrors({ '': ['Something went wrong. Please try again.'] });
    } finally {
      setSaving(false);
    }
  }


  if (loadError) return <p role="alert">{loadError}</p>;
  if (!products) return <p>Loading products…</p>;


  return (
    <main>
      <h1>Products</h1>
      <ul>{products.map(p => <li key={p.id}>{p.name} — ₹{p.price}</li>)}</ul>


      <form onSubmit={handleSubmit} noValidate>
        <label>Name <input name="name" /></label>
        {errors.Name?.map(m => <small key={m} className="error">{m}</small>)}
        <label>Price <input name="price" type="number" step="0.01" /></label>
        {errors.Price?.map(m => <small key={m} className="error">{m}</small>)}
        {errors['']?.map(m => <p key={m} role="alert">{m}</p>)}
        <button disabled={saving}>{saving ? 'Saving…' : 'Add product'}</button>
      </form>
    </main>
  );
}
Expected output
With the API running, the page shows “Loading products…”, then the list. Submitting an empty name and price 0 shows “The Name field is required.” and “The field Price must be between 0.01 and 1000000.” under the inputs — the API’s own validation messages. A valid product appears in the list immediately; an expired token sends the user back to the login screen.

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. api.ts is the only place that knows the base URL, headers, the token and how to read problem details.
  2. Errors become ApiError objects with the status and the field errors from problem.errors.
  3. ProductsPage handles loading, load errors and the list, and calls onUnauthorized on 401.
  4. On 400 the field errors are shown under the matching inputs (keys Name and Price come from the API DTO).
  5. The form element is captured before the await, so reset() still works after the request completes.

Real-world use case#

This is the skeleton of ShopSmart’s seller portal: the same api.ts grew to 40 functions, and every form shows server validation messages with the same three lines of code.

Common mistakes#

Storing tokens in localStorage

Any XSS can read localStorage. Prefer memory (with refresh via an httpOnly cookie) or a BFF pattern for browser apps.

Duplicating validation rules only in React

Client-side checks are for convenience; the API is the source of truth. Show its messages.

Ignoring 401 responses

Expired tokens should send users to login, not show a confusing generic error.

Best practices#

  • One typed API client module
  • Problem details → typed errors → field messages
  • Loading / error / data states
  • Environment-specific API URLs (VITE_API_URL)

Remember this#

API: CORS for the React origin + DTOs + problem details.

React: api.ts wraps fetch, token and errors.

400 → problem.errors → messages under fields.

Practice exercise#

Your task: Add a deleteProduct(id) function to api.ts and a Delete button per product that removes it from the list after a 204.

Need a hint?
request<void>(`/api/products/${id}`, { method: 'DELETE' }) — request already returns undefined for 204.
Show solution
TSX Illustrative
// src/api.ts (addition)
export const deleteProduct = (id: number) => request<void>(`/api/products/${id}`, { method: 'DELETE' });


// src/ProductList.tsx
import { deleteProduct, type Product } from './api';


export function ProductList({ products, onRemoved }: { products: Product[]; onRemoved: (id: number) => void }) {
  return (
    <ul>
      {products.map(p => (
        <li key={p.id}>
          {p.name} — ₹{p.price}{' '}
          <button onClick={async () => { await deleteProduct(p.id); onRemoved(p.id); }}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

Mini quiz#

React + ASP.NET Core API Project quizMultiple choice Question 1 of 3

Why must the API enable CORS for http://localhost:5173 during development?

Score so far: 0/0

Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.