Build a React CRUD App

Build a React frontend with SmallStack's CRUDView REST API

Build a React CRUD App with SmallStack's API

πŸ’‘ Prefer the big picture first? Read the one-page React Γ— SmallStack story β€” what you get for free when SmallStack is your backend β€” then come back and build it.

This tutorial walks you through building a React frontend that talks to SmallStack's built-in CRUDView REST API. By the end you'll have a working inventory manager with a real login flow, a stats dashboard, and full create / read / update / delete β€” no Django REST Framework required.

The focus is the glue: getting to a proper "hello world" against a SmallStack backend with the right tooling, authentication, and security β€” and showing off how much the backend hands you for free.

What you'll build:

  • A Django backend with three related models (Category, Supplier, Item) whose CRUDViews expose a REST API from a single line: enable_api = True
  • A real token login flow β€” no hardcoded token β€” with protected routes and a localStorage-backed session
  • A React + Vite frontend: login, a dashboard powered by the API's server-side aggregation, list pages with search / filter / pagination, and forms with validation
  • A small "React + SmallStack" badge that links to an in-app About page explaining what you got for free

Prerequisites:

  • Python 3.11+ with uv installed
  • Node.js 18+
  • Git

Ports used:

Service Port
Django backend 8050
React frontend 5173 (Vite default)

We use 8050 for the backend instead of the old default 8005, which is commonly already in use locally. You can pick any free port β€” just keep the frontend's VITE_API_URL in sync.


Step 1: Clone and set up the backend

mkdir inventory-app
cd inventory-app
git clone https://github.com/emichaud/django-smallstack.git backend
cd backend
cp .env.example .env

CORS: in development, leave CORS_ALLOWED_ORIGINS empty in .env. SmallStack's dev settings then auto-allow any http://localhost:<port> origin, so your Vite dev server can call the API without extra config:

# .env β€” leave this empty in dev; dev settings allow any localhost:* origin.
CORS_ALLOWED_ORIGINS=

If you do set explicit origins, you must include your frontend's exact origin (e.g. http://localhost:5173) or the browser will silently block every API call and the app will hang on "Loading…".

Run setup (installs deps, migrates, creates the admin/admin dev superuser), then create a non-admin demo user you could safely use for a public preview:

make setup
uv run python manage.py create_demo_user   # creates demo / demo (not staff, not superuser)

Start the backend on port 8050:

make run PORT=8050

To make 8050 the permanent default, change PORT ?= 8005 to PORT ?= 8050 in the backend Makefile, then plain make run uses it.

Verify it works. Open http://localhost:8050/health/ β€” you should see:

{"status": "ok", "database": "ok"}

And browse the interactive API docs at http://localhost:8050/api/docs/ β€” Swagger UI, auto-generated from your models. It'll be empty of inventory endpoints until the next step.

Health check


Step 2: Create your models

Create a Django app for the inventory data:

uv run python manage.py startapp inventory apps/inventory

Fix the app config

Edit apps/inventory/apps.py β€” you must set name to "apps.inventory":

from django.apps import AppConfig


class InventoryConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "apps.inventory"
    verbose_name = "Inventory"

Add the models

Replace apps/inventory/models.py. Three models: Category, Supplier, and Item (an item belongs to a category and, optionally, a supplier):

from decimal import Decimal

from django.db import models


class Category(models.Model):
    name = models.CharField(max_length=100)
    description = models.TextField(blank=True, default="")
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        verbose_name_plural = "categories"
        ordering = ["name"]

    def __str__(self):
        return self.name


class Supplier(models.Model):
    name = models.CharField(max_length=200)
    contact_email = models.EmailField(blank=True, default="")
    lead_time_days = models.PositiveIntegerField(default=7)
    is_active = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name


class Item(models.Model):
    class Status(models.TextChoices):
        ACTIVE = "active", "Active"
        DISCONTINUED = "discontinued", "Discontinued"
        BACKORDERED = "backordered", "Backordered"

    name = models.CharField(max_length=200)
    sku = models.CharField(max_length=50, unique=True)
    category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="items")
    supplier = models.ForeignKey(
        Supplier, on_delete=models.SET_NULL, null=True, blank=True, related_name="items"
    )
    quantity = models.PositiveIntegerField(default=0)
    reorder_threshold = models.PositiveIntegerField(default=0)
    unit_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"))
    status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE)
    is_active = models.BooleanField(default=True)
    description = models.TextField(blank=True, default="")
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        return f"{self.sku} Β· {self.name}"

    @property
    def is_low_stock(self) -> bool:
        """True when on-hand quantity has fallen to or below the reorder threshold."""
        return self.quantity <= self.reorder_threshold

    @property
    def stock_value(self) -> Decimal:
        """On-hand quantity valued at unit price."""
        return self.unit_price * self.quantity

is_low_stock and stock_value are plain Python properties β€” not database columns. We'll expose them read-only through the API in the next step, which is a handy trick: computed values the frontend can just read.

Register the app

Add "apps.inventory" to INSTALLED_APPS in config/settings/base.py:

INSTALLED_APPS = [
    # ...existing custom apps...
    "apps.website",
    "apps.inventory",  # Add this line
    # Django built-in apps
    "django.contrib.admin",
    # ...
]

Run migrations

uv run python manage.py makemigrations inventory
uv run python manage.py migrate

Step 3: Create CRUDViews with the API enabled

This is the key step. Setting enable_api = True on a CRUDView auto-generates REST endpoints alongside the HTML views β€” no serializers, no viewsets, no router config.

Replace apps/inventory/views.py:

from django.contrib.auth.mixins import LoginRequiredMixin

from apps.smallstack.crud import Action, CRUDView
from apps.smallstack.displays import TableDisplay

from .models import Category, Item, Supplier

_ACTIONS = [Action.LIST, Action.CREATE, Action.DETAIL, Action.UPDATE, Action.DELETE]


class CategoryCRUDView(CRUDView):
    model = Category
    fields = ["name", "description"]
    url_base = "inventory/categories"
    paginate_by = 25
    mixins = [LoginRequiredMixin]
    displays = [TableDisplay]
    actions = _ACTIONS
    enable_api = True
    search_fields = ["name"]
    api_extra_fields = ["created_at"]


class SupplierCRUDView(CRUDView):
    model = Supplier
    fields = ["name", "contact_email", "lead_time_days", "is_active"]
    url_base = "inventory/suppliers"
    paginate_by = 25
    mixins = [LoginRequiredMixin]
    displays = [TableDisplay]
    actions = _ACTIONS
    enable_api = True
    search_fields = ["name", "contact_email"]
    filter_fields = ["is_active"]
    api_extra_fields = ["created_at"]


class ItemCRUDView(CRUDView):
    model = Item
    fields = [
        "name", "sku", "category", "supplier", "quantity",
        "reorder_threshold", "unit_price", "status", "is_active", "description",
    ]
    url_base = "inventory/items"
    paginate_by = 25
    mixins = [LoginRequiredMixin]
    displays = [TableDisplay]
    actions = _ACTIONS
    enable_api = True
    search_fields = ["name", "sku", "description"]
    filter_fields = ["category", "supplier", "status", "is_active"]
    # Model properties can be exposed read-only alongside real fields:
    api_extra_fields = ["is_low_stock", "stock_value", "created_at", "updated_at"]
    api_expand_fields = ["category", "supplier"]
    # Only real DB columns are aggregatable (stock_value is a property, so it's not here):
    api_aggregate_fields = ["quantity", "unit_price"]

We use LoginRequiredMixin (not staff-only) so any authenticated user β€” including the non-admin demo user β€” can use the API.

What enable_api = True generates (same shape for every model):

Method URL Purpose
GET /api/inventory/items/ List items (paginated)
POST /api/inventory/items/ Create an item
GET /api/inventory/items/<id>/ Get one item
PATCH / PUT /api/inventory/items/<id>/ Update an item
DELETE /api/inventory/items/<id>/ Delete an item

Plus, for free, on the list endpoint:

  • Search (search_fields) β€” ?q=drill runs SmallStack's multi-field search: a case-insensitive substring match across all search_fields at once (here name, SKU, and description), OR-ed together. ?q=hex finds items by name, ?q=TL- finds them by SKU β€” one box, every field.
  • Filter (filter_fields) β€” ?status=active&category=2 is exact matching on a single field. This is a different tool from search: ?status=act matches nothing (no substring, no fuzzy). Use search to find, filters to narrow.
  • Ordering β€” ?ordering=-unit_price
  • FK expansion β€” ?expand=category,supplier returns {"id": 1, "name": "…"} instead of a bare id
  • Pagination β€” ?page=2&page_size=25
  • Aggregation β€” ?sum=quantity, ?avg=unit_price, ?count_by=status

Wire the URLs

Create apps/inventory/urls.py:

from .views import CategoryCRUDView, ItemCRUDView, SupplierCRUDView

app_name = "inventory"

urlpatterns = [
    *CategoryCRUDView.get_urls(),
    *SupplierCRUDView.get_urls(),
    *ItemCRUDView.get_urls(),
]

Add it to config/urls.py (before the website include):

urlpatterns = [
    # Inventory CRUD + API
    path("", include("apps.inventory.urls")),
    # Project pages
    path("", include("apps.website.urls")),
    # ...rest of URLs...
]

Step 4: Verify the API with a token

SmallStack ships token authentication out of the box. Instead of a hardcoded token, we'll use the login endpoint β€” the same one the React app will use. Exchange credentials for a bearer token:

# Mint a token by logging in
curl -s -X POST http://localhost:8050/api/auth/token/ \
  -H "Content-Type: application/json" \
  -d '{"username": "admin", "password": "admin"}'
# β†’ {"token": "…"}

Save that token in a shell variable and create a couple of records so the dashboard has something to show:

TOKEN=<paste-the-token>

# A category
curl -s -X POST http://localhost:8050/api/inventory/categories/ \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "Tools", "description": "Hand and power tools"}'

# An item (category id 1) that is low on stock
curl -s -X POST http://localhost:8050/api/inventory/items/ \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "Cordless Drill 18V", "sku": "TL-DRILL18", "category": 1,
       "quantity": 9, "reorder_threshold": 10, "unit_price": "129.00", "status": "active"}'

# List items, expanding the category, and see the computed fields
curl -s "http://localhost:8050/api/inventory/items/?expand=category" \
  -H "Authorization: Bearer $TOKEN"

Expected list response β€” note the standard envelope, the expanded category, and the read-only is_low_stock / stock_value:

{
  "count": 1, "page": 1, "total_pages": 1, "next": null, "previous": null,
  "results": [
    {
      "id": 1, "name": "Cordless Drill 18V", "sku": "TL-DRILL18",
      "category": {"id": 1, "name": "Tools"}, "supplier": null,
      "quantity": 9, "reorder_threshold": 10, "unit_price": "129.00",
      "status": "active", "is_active": true,
      "is_low_stock": true, "stock_value": "1161.00",
      "created_at": "…", "updated_at": "…"
    }
  ]
}

Add a few more items via curl (or later through the app) so the dashboard looks alive.


Step 5: Scaffold the React frontend

Open a new terminal. From the inventory-app directory:

npm create vite@latest frontend -- --template react
cd frontend
npm install
npm install react-router-dom
# Install the SmallStack client that ships with your backend (local path, no registry):
npm install ../backend/clients/js

Point the app at the backend. Create frontend/.env:

VITE_API_URL=http://localhost:8050

That's it β€” no token in .env. The app gets its token by logging in, and stores it in localStorage.


Step 6: The API client β€” provided by SmallStack

You could hand-roll a fetch wrapper, but SmallStack ships one. The smallstack-sdk-js client you installed gives you token auth, a generic api() call, and a resource() CRUD helper that throws an ApiError (with parsed field errors) on failure. Create src/api/client.js to configure it and expose one resource per model:

import { SmallStackClient } from 'smallstack-sdk-js'

// baseUrl is the backend origin; `persist` keeps the token in localStorage across reloads.
export const client = new SmallStackClient({
  baseUrl: import.meta.env.VITE_API_URL,
  persist: true,
})

// Each CRUDView is one resource: list / get / create / update / remove.
export const api = {
  items: client.resource('/api/inventory/items'),
  categories: client.resource('/api/inventory/categories'),
  suppliers: client.resource('/api/inventory/suppliers'),
}

// Helpers for expanded FK fields ({id, name}) vs bare ids.
export const fkId = (v) => (v == null ? '' : typeof v === 'object' ? v.id : v)
export const fkName = (v, fallback = 'β€”') =>
  v == null ? fallback : typeof v === 'object' ? v.name : `#${v}`

That's the whole client. What the SDK handles for you:

  • Token auth + persistence β€” client.auth.login/me/logout; with persist: true the token is stored and restored automatically, so a refresh keeps you signed in.
  • resource(base) β€” list(params), get(id), create(data), update(id, data), remove(id). On a non-2xx response these throw ApiError, which carries .status, .data, and β€” for validation errors β€” a ready-made .fieldErrors map ({ sku: ["Item with this Sku already exists."] }).
  • PATCH for updates (partial), 204 handling for delete, query-string building β€” all built in.

Prefer to see it done by hand? The Svelte tutorial hand-rolls the equivalent ~40-line client so you can see exactly what the SDK is doing under the hood.


Step 7: Authentication β€” done right

Rather than a hardcoded token, we log in and hold a real session. Create an auth context.

Create src/lib/AuthContext.jsx:

import { createContext, useContext, useEffect, useState } from 'react'
import { client } from '../api/client'

const AuthContext = createContext(null)

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null)
  const [ready, setReady] = useState(false)

  // On startup the SDK has already restored any persisted token (persist: true) β€”
  // try to load the current user; a 401 just means "not signed in".
  useEffect(() => {
    async function restore() {
      const res = await client.auth.me()
      if (res.ok) setUser(res.data)
      setReady(true)
    }
    restore()
  }, [])

  async function login(username, password) {
    const res = await client.auth.login(username, password) // stores + persists the token on success
    if (!res.ok) throw new Error(res.data?.error || 'Login failed')
    const me = await client.auth.me()
    setUser(me.data)
  }

  async function logout() {
    await client.auth.logout() // clears the token + localStorage
    setUser(null)
  }

  return (
    <AuthContext.Provider value={{ user, ready, login, logout }}>
      {children}
    </AuthContext.Provider>
  )
}

export const useAuth = () => useContext(AuthContext)

Create src/components/ProtectedRoute.jsx β€” redirects to the login page when there's no user:

import { Navigate } from 'react-router-dom'
import { useAuth } from '../lib/AuthContext'

export default function ProtectedRoute({ children }) {
  const { user, ready } = useAuth()
  if (!ready) return <p>Loading…</p>
  return user ? children : <Navigate to="/login" replace />
}

Create src/pages/LoginPage.jsx:

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../lib/AuthContext'
import ErrorDisplay from '../components/ErrorDisplay'

export default function LoginPage() {
  const { login } = useAuth()
  const navigate = useNavigate()
  const [username, setUsername] = useState('admin')
  const [password, setPassword] = useState('admin')
  const [error, setError] = useState(null)
  const [busy, setBusy] = useState(false)

  async function handleSubmit(e) {
    e.preventDefault()
    setBusy(true)
    setError(null)
    try {
      await login(username, password)
      navigate('/')
    } catch (err) {
      setError(err)
      setBusy(false)
    }
  }

  return (
    <div className="auth-page">
      <h1>Sign in</h1>
      <ErrorDisplay error={error} />
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label>Username</label>
          <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} />
        </div>
        <div className="form-group">
          <label>Password</label>
          <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
        </div>
        <button type="submit" className="btn" disabled={busy}>
          {busy ? 'Signing in…' : 'Sign in'}
        </button>
      </form>
      <p className="auth-alt">Dev credentials: <code>admin</code> / <code>admin</code> (or <code>demo</code> / <code>demo</code>).</p>
    </div>
  )
}

Step 8: Reusable components

Error display

Create src/components/ErrorDisplay.jsx:

import { ApiError } from 'smallstack-sdk-js'

export default function ErrorDisplay({ error }) {
  if (!error) return null

  // The SDK pre-parses 400 validation errors into error.fieldErrors
  if (error instanceof ApiError && error.fieldErrors) {
    return (
      <div className="error-box">
        <strong>Validation Errors:</strong>
        <ul>
          {Object.entries(error.fieldErrors).map(([field, messages]) => (
            <li key={field}><strong>{field}:</strong> {messages.join(', ')}</li>
          ))}
        </ul>
      </div>
    )
  }
  if (error instanceof ApiError && error.data?.error) return <div className="error-box">{error.data.error}</div>
  return <div className="error-box">{error.message || 'An error occurred'}</div>
}

SmallStack returns validation errors as {"errors": {"field": ["message"]}}, which maps cleanly to per-field display.

Pagination

Create src/components/Pagination.jsx:

export default function Pagination({ count, next, previous, page, onPageChange }) {
  if (!next && !previous) return null
  return (
    <div className="pagination">
      <button disabled={!previous} onClick={() => onPageChange(page - 1)}>Previous</button>
      <span>Page {page} ({count} total)</span>
      <button disabled={!next} onClick={() => onPageChange(page + 1)}>Next</button>
    </div>
  )
}

Step 9: Build the pages

Dashboard

The dashboard is where SmallStack's server-side aggregation shines β€” the backend computes the stats, so the frontend fetches numbers, not whole tables.

Create src/pages/Dashboard.jsx:

import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api, fkName } from '../api/client'
import ErrorDisplay from '../components/ErrorDisplay'

export default function Dashboard() {
  const [stats, setStats] = useState(null)
  const [error, setError] = useState(null)

  useEffect(() => {
    async function load() {
      try {
        const [byStatus, qtyAgg, items] = await Promise.all([
          api.items.list({ count_by: 'status' }),          // group counts
          api.items.list({ sum: 'quantity' }),             // total on-hand
          api.items.list({ page_size: 200, expand: 'category' }), // for low-stock + value
        ])
        const lowStock = items.results.filter((i) => i.is_low_stock)
        const value = items.results.reduce((s, i) => s + parseFloat(i.stock_value || '0'), 0)
        setStats({
          total: byStatus.count,
          counts: byStatus.counts || {},
          onHand: qtyAgg.sum_quantity ?? 0,
          lowStock,
          value,
        })
      } catch (err) {
        setError(err)
      }
    }
    load()
  }, [])

  return (
    <div>
      <h1>Dashboard</h1>
      <ErrorDisplay error={error} />
      {stats ? (
        <>
          <div className="stats-grid">
            <div className="stat-card">
              <h2>{stats.total}</h2><p>Items</p>
              <Link to="/items">View all</Link>
            </div>
            <div className="stat-card">
              <h2>{stats.onHand.toLocaleString()}</h2><p>On-hand units</p>
            </div>
            <div className="stat-card">
              <h2>{stats.lowStock.length}</h2><p>Low stock</p>
            </div>
            <div className="stat-card">
              <h2>${stats.value.toLocaleString(undefined, { maximumFractionDigits: 0 })}</h2>
              <p>Inventory value</p>
            </div>
          </div>

          <h2 style={{ marginTop: 24 }}>Low-stock items</h2>
          <table>
            <thead>
              <tr><th>SKU</th><th>Name</th><th>Category</th><th>On hand</th><th>Reorder at</th></tr>
            </thead>
            <tbody>
              {stats.lowStock.length === 0 ? (
                <tr><td colSpan="5">Nothing low on stock β€” nice.</td></tr>
              ) : stats.lowStock.map((i) => (
                <tr key={i.id}>
                  <td><Link to={`/items/${i.id}/edit`}>{i.sku}</Link></td>
                  <td>{i.name}</td>
                  <td>{fkName(i.category)}</td>
                  <td>{i.quantity}</td>
                  <td>{i.reorder_threshold}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </>
      ) : !error ? <p>Loading…</p> : null}
    </div>
  )
}

Dashboard

Aggregation beats fetching every page. ?count_by=status and ?sum=quantity return real totals across all rows in one request. is_low_stock isn't a database column, so we compute the low-stock list client-side from an expanded fetch β€” but the headline numbers come straight from the backend.

Items list

This page shows off search (?q=), a status filter, FK expansion, pagination, and low-stock highlighting. Create src/pages/ItemsList.jsx:

import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { api, fkName } from '../api/client'
import ErrorDisplay from '../components/ErrorDisplay'
import Pagination from '../components/Pagination'

export default function ItemsList() {
  const [searchParams, setSearchParams] = useSearchParams()
  const [data, setData] = useState(null)
  const [error, setError] = useState(null)
  const [search, setSearch] = useState(searchParams.get('q') || '')

  const page = parseInt(searchParams.get('page') || '1', 10)
  const status = searchParams.get('status') || ''

  async function load() {
    try {
      setError(null)
      const result = await api.items.list({
        page: page > 1 ? page : undefined,
        q: searchParams.get('q') || undefined,      // NOTE: search param is ?q=, not ?search=
        status: status || undefined,
        ordering: 'name',
        expand: 'category,supplier',
      })
      setData(result)
    } catch (err) {
      setError(err)
    }
  }

  useEffect(() => { load() }, [searchParams.toString()])

  async function handleDelete(id) {
    if (!confirm('Delete this item?')) return
    try { await api.items.remove(id); load() } catch (err) { setError(err) }
  }

  function setParam(key, value) {
    const params = Object.fromEntries(searchParams)
    if (value) params[key] = value; else delete params[key]
    delete params.page
    setSearchParams(params)
  }

  function handleSearch(e) {
    e.preventDefault()
    setParam('q', search)
  }

  return (
    <div>
      <div className="page-header">
        <h1>Items</h1>
        <Link to="/items/new" className="btn">+ New Item</Link>
      </div>

      <form onSubmit={handleSearch} className="search-form">
        <input
          type="text"
          placeholder="Search name, SKU, description…"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
        />
        <button type="submit">Search</button>
      </form>

      <div className="filters">
        <select value={status} onChange={(e) => setParam('status', e.target.value)}>
          <option value="">All statuses</option>
          <option value="active">Active</option>
          <option value="backordered">Backordered</option>
          <option value="discontinued">Discontinued</option>
        </select>
      </div>

      <ErrorDisplay error={error} />

      {data ? (
        <>
          <table>
            <thead>
              <tr>
                <th>SKU</th><th>Name</th><th>Category</th><th>Supplier</th>
                <th>Qty</th><th>Unit price</th><th>Status</th><th>Actions</th>
              </tr>
            </thead>
            <tbody>
              {data.results.length === 0 ? (
                <tr><td colSpan="8">No items found.</td></tr>
              ) : data.results.map((item) => (
                <tr key={item.id} style={item.is_low_stock ? { background: '#fff7e6' } : undefined}>
                  <td><Link to={`/items/${item.id}/edit`}>{item.sku}</Link></td>
                  <td>{item.name}</td>
                  <td>{fkName(item.category)}</td>
                  <td>{fkName(item.supplier)}</td>
                  <td>{item.quantity}{item.is_low_stock ? ' ⚠️' : ''}</td>
                  <td>${item.unit_price}</td>
                  <td><span className="badge">{item.status}</span></td>
                  <td>
                    <Link to={`/items/${item.id}/edit`}>Edit</Link>{' | '}
                    <button className="btn-link danger" onClick={() => handleDelete(item.id)}>Delete</button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
          <Pagination
            count={data.count} next={data.next} previous={data.previous} page={page}
            onPageChange={(p) => setSearchParams({ ...Object.fromEntries(searchParams), page: p })}
          />
        </>
      ) : !error ? <p>Loading…</p> : null}
    </div>
  )
}

Items list

?q= is the search param, not ?search=. Unknown query params are silently ignored β€” so ?search= would return every row and your search box would look like it "works" while filtering nothing. When a filter seems to do nothing, check the param name first.

Item form (create + edit)

One component handles both. The category and supplier dropdowns are populated from the API. Create src/pages/ItemForm.jsx:

import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { ApiError } from 'smallstack-sdk-js'
import { api, fkId } from '../api/client'
import ErrorDisplay from '../components/ErrorDisplay'

const EMPTY = {
  name: '', sku: '', category: '', supplier: '', quantity: 0,
  reorder_threshold: 0, unit_price: '0.00', status: 'active', is_active: true, description: '',
}

export default function ItemForm() {
  const { id } = useParams()
  const navigate = useNavigate()
  const isEdit = Boolean(id)

  const [form, setForm] = useState(EMPTY)
  const [categories, setCategories] = useState([])
  const [suppliers, setSuppliers] = useState([])
  const [error, setError] = useState(null)
  const [fieldErrors, setFieldErrors] = useState({})
  const [saving, setSaving] = useState(false)

  // Options for the dropdowns
  useEffect(() => {
    api.categories.list({ page_size: 200, ordering: 'name' }).then((r) => setCategories(r.results)).catch(() => {})
    api.suppliers.list({ page_size: 200, ordering: 'name' }).then((r) => setSuppliers(r.results)).catch(() => {})
  }, [])

  // Load the item when editing
  useEffect(() => {
    if (!isEdit) return
    api.items.get(id).then((data) => setForm({
      name: data.name, sku: data.sku,
      category: String(fkId(data.category)),
      supplier: data.supplier ? String(fkId(data.supplier)) : '',
      quantity: data.quantity, reorder_threshold: data.reorder_threshold,
      unit_price: data.unit_price, status: data.status,
      is_active: data.is_active, description: data.description || '',
    })).catch((err) => setError(err))
  }, [id])

  async function handleSubmit(e) {
    e.preventDefault()
    setSaving(true); setError(null); setFieldErrors({})
    const payload = {
      ...form,
      category: parseInt(form.category, 10) || null,
      supplier: form.supplier ? parseInt(form.supplier, 10) : null,
      quantity: Number(form.quantity),
      reorder_threshold: Number(form.reorder_threshold),
    }
    try {
      if (isEdit) await api.items.update(id, payload)
      else await api.items.create(payload)
      navigate('/items')
    } catch (err) {
      if (err instanceof ApiError && err.fieldErrors) setFieldErrors(err.fieldErrors)
      setError(err); setSaving(false)
    }
  }

  const set = (k) => (e) =>
    setForm({ ...form, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value })

  return (
    <div>
      <h1>{isEdit ? 'Edit Item' : 'New Item'}</h1>
      <ErrorDisplay error={error} />
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label>Name</label>
          <input type="text" value={form.name} onChange={set('name')} />
          {fieldErrors.name && <span className="field-error">{fieldErrors.name.join(', ')}</span>}
        </div>
        <div className="form-group">
          <label>SKU</label>
          <input type="text" value={form.sku} onChange={set('sku')} />
          {fieldErrors.sku && <span className="field-error">{fieldErrors.sku.join(', ')}</span>}
        </div>
        <div className="form-group">
          <label>Category</label>
          <select value={form.category} onChange={set('category')}>
            <option value="">-- Select --</option>
            {categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
          </select>
          {fieldErrors.category && <span className="field-error">{fieldErrors.category.join(', ')}</span>}
        </div>
        <div className="form-group">
          <label>Supplier</label>
          <select value={form.supplier} onChange={set('supplier')}>
            <option value="">β€” none β€”</option>
            {suppliers.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
          </select>
        </div>
        <div className="form-group">
          <label>Quantity</label>
          <input type="number" min="0" value={form.quantity} onChange={set('quantity')} />
        </div>
        <div className="form-group">
          <label>Reorder threshold</label>
          <input type="number" min="0" value={form.reorder_threshold} onChange={set('reorder_threshold')} />
        </div>
        <div className="form-group">
          <label>Unit price</label>
          <input type="number" step="0.01" min="0" value={form.unit_price} onChange={set('unit_price')} />
          {fieldErrors.unit_price && <span className="field-error">{fieldErrors.unit_price.join(', ')}</span>}
        </div>
        <div className="form-group">
          <label>Status</label>
          <select value={form.status} onChange={set('status')}>
            <option value="active">Active</option>
            <option value="backordered">Backordered</option>
            <option value="discontinued">Discontinued</option>
          </select>
        </div>
        <div className="form-group">
          <label><input type="checkbox" checked={form.is_active} onChange={set('is_active')} /> Active (stocked)</label>
        </div>
        <div className="form-group">
          <label>Description</label>
          <textarea value={form.description} onChange={set('description')} />
        </div>
        <div className="form-actions">
          <button type="submit" className="btn" disabled={saving}>
            {saving ? 'Saving…' : isEdit ? 'Update' : 'Create'}
          </button>
          <button type="button" onClick={() => navigate('/items')}>Cancel</button>
        </div>
      </form>
    </div>
  )
}

Editing an expanded FK: because api_expand_fields returns category: {"id", "name"}, extract the id when loading a record into the form β€” the fkId() helper does this. Categories/suppliers are still fetched separately to populate the dropdown options; expansion solves display, not option lists.


Step 10: The "React + SmallStack" badge and About page

A small touch that makes the app feel finished: an unobtrusive badge that links to a one-page explainer of how the frontend and SmallStack fit together.

Create src/components/BuiltWith.jsx:

import { Link } from 'react-router-dom'

export default function BuiltWith({ framework = 'React' }) {
  return (
    <Link className="built-with" to="/about" title="How the frontend and SmallStack fit together">
      <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
        <path d="M13 10V3L4 14h7v7l9-11h-7z" />
      </svg>
      <span>{framework} <span className="bw-x">+</span> SmallStack</span>
    </Link>
  )
}

Create src/pages/About.jsx (a public page β€” no login required):

import { Link } from 'react-router-dom'

const backend = (import.meta.env.VITE_API_URL || 'http://localhost:8050').replace(/\/+$/, '')

export default function About() {
  return (
    <div className="about">
      <Link className="about-back" to="/">← Back to the dashboard</Link>
      <h1>React <span style={{ color: '#1a73e8' }}>+</span> SmallStack</h1>
      <p>
        This inventory app is a <strong>React</strong> frontend talking to a <strong>Django SmallStack</strong>{' '}
        backend. SmallStack does the heavy lifting behind a clean REST API, so the UI code stays small.
      </p>
      <h3>How it fits together</h3>
      <ol>
        <li><strong>Models β†’ API in one line.</strong> A CRUDView with <code>enable_api = True</code> becomes a full REST resource.</li>
        <li><strong>This app just fetches JSON.</strong> It signs in for a token, then reads and writes those endpoints.</li>
        <li><strong>No backend glue.</strong> No serializers, no viewsets, no router config.</li>
      </ol>
      <h3>What SmallStack handed us for free</h3>
      <ul>
        <li>A complete, documented REST API generated from the models</li>
        <li>Token auth + registration + user management</li>
        <li>Auto-generated API docs β€” Swagger &amp; ReDoc</li>
        <li>Search, filtering, ordering, FK expansion, and pagination</li>
        <li>Server-side aggregation powering the dashboard</li>
        <li>A built-in data explorer to browse the same records</li>
      </ul>
      <div className="about-links">
        <a className="btn" href={`${backend}/api/docs/`} target="_blank" rel="noreferrer">Swagger API docs β†—</a>
        <a className="btn btn-secondary" href={`${backend}/api/redoc/`} target="_blank" rel="noreferrer">ReDoc (OpenAPI) β†—</a>
        <a className="btn btn-secondary" href={`${backend}/smallstack/explorer/`} target="_blank" rel="noreferrer">SmallStack Explorer β†—</a>
      </div>
    </div>
  )
}

About page


Step 11: Wire up the app

Replace src/main.jsx to wrap the app in the auth provider:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { AuthProvider } from './lib/AuthContext'
import App from './App.jsx'
import './index.css'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <AuthProvider>
      <App />
    </AuthProvider>
  </StrictMode>,
)

Replace src/App.jsx β€” the nav shows the current user and a logout button, protected routes are wrapped in ProtectedRoute, and /login and /about are public:

import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom'
import { useAuth } from './lib/AuthContext'
import ProtectedRoute from './components/ProtectedRoute'
import BuiltWith from './components/BuiltWith'
import Dashboard from './pages/Dashboard'
import LoginPage from './pages/LoginPage'
import ItemsList from './pages/ItemsList'
import ItemForm from './pages/ItemForm'
import About from './pages/About'
import './App.css'

function Nav() {
  const { user, logout } = useAuth()
  return (
    <nav className="topnav">
      <span className="brand">Inventory Manager</span>
      <NavLink to="/">Dashboard</NavLink>
      {user && <NavLink to="/items">Items</NavLink>}
      <div className="nav-right">
        {user ? (
          <>
            <span className="nav-user">{user.username}</span>
            <button className="btn-link" onClick={logout}>Logout</button>
          </>
        ) : (
          <NavLink to="/login">Login</NavLink>
        )}
        <BuiltWith framework="React" />
      </div>
    </nav>
  )
}

export default function App() {
  return (
    <BrowserRouter>
      <div className="app">
        <Nav />
        <main className="content">
          <Routes>
            <Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
            <Route path="/login" element={<LoginPage />} />
            <Route path="/about" element={<About />} />
            <Route path="/items" element={<ProtectedRoute><ItemsList /></ProtectedRoute>} />
            <Route path="/items/new" element={<ProtectedRoute><ItemForm /></ProtectedRoute>} />
            <Route path="/items/:id/edit" element={<ProtectedRoute><ItemForm /></ProtectedRoute>} />
          </Routes>
        </main>
      </div>
    </BrowserRouter>
  )
}

Replace src/index.css:

* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: system-ui, -apple-system, sans-serif;
  font-size: 15px; line-height: 1.5; color: #333;
}
h1, h2 { margin: 0 0 12px; }
h1 { font-size: 24px; }
a { color: #1a73e8; }
code { background: #eef1f4; color: #1a4b8e; padding: 1px 5px; border-radius: 4px; }

Replace src/App.css:

.app { max-width: 960px; margin: 0 auto; padding: 0 16px; }

.topnav {
  display: flex; gap: 16px; align-items: center;
  padding: 12px 0; border-bottom: 1px solid #ddd; margin-bottom: 24px; flex-wrap: wrap;
}
.topnav .brand { font-weight: bold; margin-right: auto; }
.topnav a { text-decoration: none; color: #555; }
.topnav a.active { color: #1a73e8; font-weight: 600; }
.nav-right { display: flex; gap: 12px; align-items: center; }
.nav-user { color: #666; font-size: 14px; }

.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }

.btn {
  background: #1a73e8; color: white; border: none; padding: 8px 16px;
  border-radius: 4px; cursor: pointer; text-decoration: none; font-size: 14px;
}
.btn:hover { background: #1557b0; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary { background: #fff; color: #1a73e8; border: 1px solid #d3e3fd; }
.btn-secondary:hover { background: #f0f6ff; }
.btn-link { background: none; border: none; cursor: pointer; padding: 0; text-decoration: underline; font-size: inherit; color: #1a73e8; }
.btn-link.danger { color: #d32f2f; }

table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #eee; }
th { font-weight: 600; background: #f9f9f9; }

.search-form { display: flex; gap: 8px; margin-bottom: 12px; }
.search-form input { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
.filters { display: flex; gap: 8px; margin-bottom: 16px; }
.filters select { padding: 8px; border: 1px solid #ddd; border-radius: 4px; }

.pagination { display: flex; gap: 12px; align-items: center; justify-content: center; padding: 12px 0; }

.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-weight: 600; margin-bottom: 4px; }
.form-group input[type="text"], .form-group input[type="number"],
.form-group input[type="password"], .form-group textarea, .form-group select {
  width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box;
}
.form-group textarea { min-height: 80px; }
.form-actions { display: flex; gap: 8px; }
.field-error { color: #d32f2f; font-size: 13px; display: block; margin-top: 4px; }

.error-box { background: #fce4ec; color: #c62828; padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; }
.error-box ul { margin: 4px 0 0; padding-left: 20px; }

.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-top: 16px; }
.stat-card { background: #f9f9f9; border: 1px solid #eee; border-radius: 8px; padding: 20px; text-align: center; }
.stat-card h2 { font-size: 32px; margin: 0; }
.stat-card p { color: #666; margin: 4px 0 8px; }
.stat-card a { color: #1a73e8; text-decoration: none; }

.badge { background: #e3f2fd; color: #1565c0; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; text-transform: capitalize; }

.auth-page { max-width: 380px; margin: 40px auto; }
.auth-alt { margin-top: 16px; color: #666; font-size: 14px; }

/* Badge + About */
.built-with {
  display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
  border-radius: 999px; border: 1px solid #d3e3fd; background: #f0f6ff;
  color: #1a73e8; font-size: 12px; font-weight: 600; text-decoration: none;
}
.built-with:hover { border-color: #1a73e8; background: #e3f2fd; }
.about { max-width: 720px; }
.about-back { display: inline-block; margin-bottom: 12px; color: #666; text-decoration: none; }
.about-links { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }

Step 12: Run it

Terminal 1 β€” backend:

cd inventory-app/backend
make run PORT=8050

Terminal 2 β€” frontend:

cd inventory-app/frontend
npm run dev

Open http://localhost:5173. You'll land on the login page β€” sign in with admin / admin (or demo / demo) and the dashboard loads with live data from the API.

Login


API Reference

Everything below comes from enable_api = True β€” no serializers, no viewsets.

Endpoints

Method URL Body Response
GET /api/{url_base}/ β€” {"count", "page", "total_pages", "next", "previous", "results": [...]}
POST /api/{url_base}/ JSON object 201 with created object
GET /api/{url_base}/<id>/ β€” JSON object
PATCH /api/{url_base}/<id>/ Partial JSON Updated object
DELETE /api/{url_base}/<id>/ β€” 204 No Content

Auth

Method URL Purpose
POST /api/auth/token/ Exchange {username, password} for {token}
GET /api/auth/me/ Current user
POST /api/auth/logout/ Invalidate the token

Send the token as Authorization: Bearer <token> on every request.

Query parameters (list endpoint)

Param Example Requires
?q=term ?q=drill search_fields
?field=value ?status=active&category=2 filter_fields
?ordering=field ?ordering=-unit_price β€”
?expand=f1,f2 ?expand=category,supplier api_expand_fields
?page=N&page_size=N ?page=2&page_size=25 automatic
?count_by=field ?count_by=status field in filter_fields
?sum=field ?avg=field ?sum=quantity field in api_aggregate_fields

Error responses

// 400 β€” validation (per-field)
{"errors": {"sku": ["Item with this Sku already exists."]}}
// 401 β€” bad/missing token
{"error": "Invalid token"}
// 404 β€” not found
{"error": "Not found"}

Tips and gotchas

Search (?q=) vs. filtering β€” they're different tools

The search box uses ?q=, which is SmallStack's powerful multi-field search: a case-insensitive substring match across every field in search_fields (name, SKU, description), OR-ed together. That's why the one search box finds items whether the term is in the name (?q=hex), the SKU (?q=TL-), or the description.

The dropdowns use filters (?status=, ?category=) β€” exact matches on a single field. Search finds; filters narrow. Reach for search_fields when you want a Google-style box, and filter_fields for precise facets.

Two gotchas, both stemming from the API silently ignoring params it doesn't understand:

  • The search param is ?q=, not ?search=. ?search=drill returns everything (the unknown param is dropped), so the box looks like it works while filtering nothing.
  • An invalid filter value is ignored, not rejected. ?status=act returns all rows, not zero. When a filter seems to do nothing, check the param name and value first.

FK expansion eliminates client-side joins

With api_expand_fields = ["category", "supplier"], FK fields arrive as {"id", "name"} β€” display with fkName(item.category). When loading a record into a form, extract the id with fkId(...). Expansion solves display; you still fetch the options list separately for dropdowns.

Server-side aggregation replaces all-page fetching

Use ?count_by=, ?sum=, ?avg= instead of fetching every page to compute stats in JavaScript (which only sees the first page). count_by fields must be in filter_fields; sum/avg fields must be in api_aggregate_fields (real DB columns only β€” not properties).

Model properties as read-only API fields

is_low_stock and stock_value are Python properties, not columns. Listing them in api_extra_fields exposes them read-only in responses β€” great for computed values the frontend shouldn't have to recalculate (though you can't filter or aggregate on them server-side).

Auth done right

No hardcoded token: the app logs in, stores the bearer token in localStorage, and restores the session on reload via /api/auth/me/. ProtectedRoute bounces anonymous users to /login. For a public preview, use the non-staff demo user so the Django admin stays protected.

CORS in development

Leave CORS_ALLOWED_ORIGINS empty in .env β€” dev settings auto-allow any localhost:* origin. If you set explicit origins, include your frontend's exact origin or every request will fail (symptom: the app hangs on "Loading…" while curl works).

PATCH for partial updates

Use PATCH (not PUT) β€” send only the fields you're changing: await api.items.update(id, { quantity: 5 }).


File structure

inventory-app/
β”œβ”€β”€ backend/                          # SmallStack clone
β”‚   β”œβ”€β”€ apps/inventory/
β”‚   β”‚   β”œβ”€β”€ models.py                 # Category, Supplier, Item (+ is_low_stock/stock_value)
β”‚   β”‚   β”œβ”€β”€ views.py                  # CRUDViews with enable_api=True
β”‚   β”‚   β”œβ”€β”€ urls.py                   # URL wiring
β”‚   β”‚   └── apps.py                   # name = "apps.inventory"
β”‚   β”œβ”€β”€ config/settings/base.py       # INSTALLED_APPS
β”‚   β”œβ”€β”€ config/urls.py                # include inventory URLs
β”‚   └── .env                          # empty CORS in dev
β”‚
└── frontend/                         # React + Vite
    β”œβ”€β”€ .env                          # VITE_API_URL=http://localhost:8050  (no token!)
    └── src/
        β”œβ”€β”€ api/client.js             # configures the SmallStack SDK client + resources
        β”œβ”€β”€ lib/AuthContext.jsx       # login/logout/me, token in localStorage
        β”œβ”€β”€ components/
        β”‚   β”œβ”€β”€ ProtectedRoute.jsx    # redirects anonymous users to /login
        β”‚   β”œβ”€β”€ ErrorDisplay.jsx      # renders API validation errors
        β”‚   β”œβ”€β”€ Pagination.jsx        # page navigation
        β”‚   └── BuiltWith.jsx         # "React + SmallStack" badge β†’ /about
        β”œβ”€β”€ pages/
        β”‚   β”œβ”€β”€ LoginPage.jsx         # token login flow
        β”‚   β”œβ”€β”€ Dashboard.jsx         # aggregation stats + low-stock
        β”‚   β”œβ”€β”€ ItemsList.jsx         # search + filter + expand + pagination
        β”‚   β”œβ”€β”€ ItemForm.jsx          # create + edit, FK selects, choice, decimal
        β”‚   └── About.jsx             # "look what you get" explainer
        β”œβ”€β”€ App.jsx                   # router + nav + auth
        β”œβ”€β”€ App.css                   # styles
        └── index.css                 # base styles

That's a complete, authenticated CRUD app against a SmallStack backend β€” and almost all of the "backend" was one line per model. The same inventory app is also built with Svelte in this tutorial series.