Full Stack — Frontend and Backend Together
how the two halves connect into one working product.
the big picture
a full stack app has two halves that work together:
user's browser your server
┌─────────────────┐ ┌──────────────────┐
│ FRONTEND │ HTTP/JSON │ BACKEND │
│ │◄────────────►│ │
│ React (UI) │ │ FastAPI (logic) │
│ HTML/CSS/JS │ │ PostgreSQL (data)│
│ State │ │ Auth │
└─────────────────┘ └──────────────────┘
the frontend shows the UI and handles user interactions. the backend processes logic, enforces rules, and talks to the database. they communicate through an API — the contract between them.
why separation matters
security — frontend code runs in the user’s browser. anyone can see it, modify it, inspect it. never put secrets, business logic, or direct database access in the frontend. the backend is the gatekeeper.
flexibility — with a good API, you can build a web app, Android app, and iOS app all using the same backend.
scaling — frontend and backend can be scaled independently. if your UI gets millions of views (CDN), your API can scale separately.
team work — frontend and backend developers work in parallel once the API contract is agreed.
the request-response cycle
every interaction follows this cycle:
1. user clicks "Load Goals" button
2. frontend runs:
const goals = await fetch('/api/goals', {
headers: { 'Authorization': `Bearer ${token}` }
})
3. HTTP request leaves browser:
GET /api/goals HTTP/1.1
Host: api.studentos.com
Authorization: Bearer eyJ...
4. backend receives request:
- validates JWT token (who is this?)
- checks permissions (are they allowed?)
- queries database (get their goals)
- formats response as JSON
5. HTTP response goes back:
HTTP/1.1 200 OK
Content-Type: application/json
{ "data": [...goals] }
6. frontend receives response:
- parses JSON
- updates state: setGoals(data)
- React re-renders UI with goals
7. user sees their goals
project structure for full stack
my-app/
├── frontend/ → React app
│ ├── src/
│ │ ├── components/
│ │ ├── pages/
│ │ ├── hooks/
│ │ ├── lib/
│ │ │ └── api.js → all API calls
│ │ └── App.jsx
│ ├── .env
│ └── package.json
│
└── backend/ → FastAPI app
├── main.py → FastAPI app, routes
├── models.py → Pydantic models
├── database.py → DB connection
├── auth.py → JWT logic
├── routers/
│ ├── goals.py → /api/goals routes
│ ├── logs.py → /api/logs routes
│ └── users.py → /api/users routes
├── requirements.txt
└── .env
the API layer — the contract
the API is the contract between frontend and backend. agree on it before building either side.
design the API first — write down every endpoint, its method, request body, and response shape. both sides build to this contract.
GOALS API:
GET /api/goals
auth: required
query: ?date=2026-03-06
response: { data: Goal[], count: number }
POST /api/goals
auth: required
body: { title: string, date: string }
response: { data: Goal }
errors: 400 (invalid), 401 (no auth)
PATCH /api/goals/:id
auth: required
body: { title?: string, completed?: boolean }
response: { data: Goal }
errors: 404 (not found), 403 (not yours)
DELETE /api/goals/:id
auth: required
response: 204 No Content
errors: 404, 403
building the API client (frontend)
create one place in your frontend that handles all API communication. never scatter fetch calls across components.
// frontend/src/lib/api.js
const BASE_URL = import.meta.env.VITE_API_URL
// helper to get auth token
const getToken = () => localStorage.getItem('token')
// base request function
async function request(method, path, body = null) {
const headers = {
'Content-Type': 'application/json'
}
const token = getToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const config = {
method,
headers
}
if (body) {
config.body = JSON.stringify(body)
}
const response = await fetch(`${BASE_URL}${path}`, config)
// handle no content (DELETE)
if (response.status === 204) return null
const data = await response.json()
// throw error with server message
if (!response.ok) {
throw new Error(data.detail || data.message || 'Something went wrong')
}
return data
}
// clean API interface
export const api = {
get: (path) => request('GET', path),
post: (path, body) => request('POST', path, body),
patch: (path, body) => request('PATCH', path, body),
delete: (path) => request('DELETE', path)
}
// feature-specific functions
export const goalsApi = {
list: (date) => api.get(`/api/goals${date ? `?date=${date}` : ''}`),
create: (goal) => api.post('/api/goals', goal),
update: (id, updates) => api.patch(`/api/goals/${id}`, updates),
delete: (id) => api.delete(`/api/goals/${id}`)
}
export const authApi = {
login: (credentials) => api.post('/api/auth/login', credentials),
signup: (userData) => api.post('/api/auth/signup', userData),
logout: () => api.post('/api/auth/logout')
}using the API in React components
// hooks/useGoals.js — custom hook for goals
import { useState, useEffect, useCallback } from 'react'
import { goalsApi } from '../lib/api'
export function useGoals(date) {
const [goals, setGoals] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchGoals = useCallback(async () => {
setLoading(true)
setError(null)
try {
const { data } = await goalsApi.list(date)
setGoals(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [date])
useEffect(() => {
fetchGoals()
}, [fetchGoals])
const createGoal = async (title) => {
try {
const { data } = await goalsApi.create({ title, date })
setGoals(prev => [...prev, data])
} catch (err) {
setError(err.message)
}
}
const toggleGoal = async (id) => {
const goal = goals.find(g => g.id === id)
try {
setGoals(prev => prev.map(g =>
g.id === id ? { ...g, completed: !g.completed } : g
))
await goalsApi.update(id, { completed: !goal.completed })
} catch (err) {
// revert on failure
setGoals(prev => prev.map(g =>
g.id === id ? { ...g, completed: goal.completed } : g
))
setError(err.message)
}
}
const deleteGoal = async (id) => {
try {
setGoals(prev => prev.filter(g => g.id !== id))
await goalsApi.delete(id)
} catch (err) {
fetchGoals() // refetch on failure
setError(err.message)
}
}
return { goals, loading, error, createGoal, toggleGoal, deleteGoal }
}
// Dashboard.jsx — uses the hook
function Dashboard() {
const today = new Date().toISOString().split('T')[0]
const { goals, loading, error, createGoal, toggleGoal, deleteGoal } = useGoals(today)
if (loading) return <LoadingSpinner />
if (error) return <ErrorMessage message={error} />
return (
<div>
<GoalForm onSubmit={createGoal} />
<GoalList
goals={goals}
onToggle={toggleGoal}
onDelete={deleteGoal}
/>
</div>
)
}backend routes (FastAPI)
# backend/routers/goals.py
from fastapi import APIRouter, Depends, HTTPException
from typing import Optional
from datetime import date as DateType
from ..auth import get_current_user
from ..database import db
from ..models import GoalCreate, GoalUpdate, GoalResponse
router = APIRouter(prefix="/api/goals", tags=["goals"])
@router.get("", response_model=dict)
async def list_goals(
date: Optional[DateType] = None,
user_id: str = Depends(get_current_user)
):
goals = await db.goals.find(user_id=user_id, date=date)
return { "data": goals, "count": len(goals) }
@router.post("", response_model=dict, status_code=201)
async def create_goal(
goal: GoalCreate,
user_id: str = Depends(get_current_user)
):
created = await db.goals.create({
**goal.dict(),
"user_id": user_id
})
return { "data": created }
@router.patch("/{goal_id}", response_model=dict)
async def update_goal(
goal_id: str,
updates: GoalUpdate,
user_id: str = Depends(get_current_user)
):
goal = await db.goals.find_one(id=goal_id)
if not goal:
raise HTTPException(404, "Goal not found")
if goal.user_id != user_id:
raise HTTPException(403, "Not your goal")
updated = await db.goals.update(
goal_id,
updates.dict(exclude_none=True)
)
return { "data": updated }
@router.delete("/{goal_id}", status_code=204)
async def delete_goal(
goal_id: str,
user_id: str = Depends(get_current_user)
):
goal = await db.goals.find_one(id=goal_id)
if not goal:
raise HTTPException(404, "Goal not found")
if goal.user_id != user_id:
raise HTTPException(403, "Not your goal")
await db.goals.delete(goal_id)
# backend/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routers import goals, logs, users, auth
app = FastAPI()
app.add_middleware(CORSMiddleware,
allow_origins=["http://localhost:5173", "https://app.abhishekkumar.xyz"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
app.include_router(auth.router)
app.include_router(goals.router)
app.include_router(logs.router)
app.include_router(users.router)authentication flow end to end
SIGNUP:
frontend: backend:
user fills form POST /api/auth/signup
→ POST /api/auth/signup → validate input
{ name, email, password } check email not taken
hash password (bcrypt)
← save user to DB
{ token, user } create JWT
→ store token return token + user
→ redirect to dashboard
LOGIN:
frontend: backend:
user fills form POST /api/auth/login
→ POST /api/auth/login → find user by email
{ email, password } verify password hash
← if match: create JWT
{ token, user } return token + user
→ store token
→ redirect to dashboard
PROTECTED REQUEST:
frontend: backend:
→ GET /api/goals → extract token from header
Authorization: Bearer... verify JWT signature
check expiry
get user_id from token
query DB for their goals
← return goals
{ data: [...] }
→ update state
→ render goals
implementation:
// frontend/src/context/AuthContext.jsx
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// check if already logged in
const token = localStorage.getItem('token')
const savedUser = localStorage.getItem('user')
if (token && savedUser) {
setUser(JSON.parse(savedUser))
}
setLoading(false)
}, [])
const login = async (email, password) => {
const { token, user } = await authApi.login({ email, password })
localStorage.setItem('token', token)
localStorage.setItem('user', JSON.stringify(user))
setUser(user)
}
const logout = () => {
localStorage.removeItem('token')
localStorage.removeItem('user')
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{!loading && children}
</AuthContext.Provider>
)
}optimistic updates
update the UI immediately, then sync with backend. makes app feel instant.
const toggleGoal = async (id) => {
const goal = goals.find(g => g.id === id)
const newCompleted = !goal.completed
// 1. update UI immediately (optimistic)
setGoals(prev => prev.map(g =>
g.id === id ? { ...g, completed: newCompleted } : g
))
try {
// 2. sync with backend
await goalsApi.update(id, { completed: newCompleted })
} catch (err) {
// 3. revert if backend fails
setGoals(prev => prev.map(g =>
g.id === id ? { ...g, completed: goal.completed } : g
))
showError('Failed to update goal')
}
}error handling across the stack
errors should be handled at every layer:
database error
↓
backend catches it, returns appropriate HTTP status + message
↓
frontend api.js receives non-ok response, throws Error with message
↓
component catches error, sets error state
↓
UI shows error message to user
# backend — always return clear error messages
@router.post("/api/goals")
async def create_goal(goal: GoalCreate):
try:
created = await db.goals.create(goal.dict())
return { "data": created }
except UniqueViolation:
raise HTTPException(409, "Goal already exists for this date")
except Exception as e:
logger.error(f"Failed to create goal: {e}")
raise HTTPException(500, "Failed to create goal")// frontend — one place to handle API errors
async function request(method, path, body) {
try {
const response = await fetch(...)
const data = await response.json()
if (!response.ok) {
// handle specific error codes
if (response.status === 401) {
logout() // token expired, logout user
throw new Error('Session expired')
}
throw new Error(data.detail || 'Request failed')
}
return data
} catch (err) {
if (err.name === 'TypeError') {
// network error (no internet, server down)
throw new Error('Connection failed. Check your internet.')
}
throw err
}
}environment configuration
development:
frontend: localhost:5173
backend: localhost:8000
database: local PostgreSQL or Supabase
production:
frontend: app.abhishekkumar.xyz (Vercel)
backend: api.abhishekkumar.xyz (Railway)
database: Supabase
# frontend/.env.development
VITE_API_URL=http://localhost:8000
# frontend/.env.production
VITE_API_URL=https://api.abhishekkumar.xyz
# backend/.env
DATABASE_URL=postgresql://...
JWT_SECRET=long-random-secret
CORS_ORIGINS=http://localhost:5173,https://app.abhishekkumar.xyzrunning full stack locally
# terminal 1 — backend
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload --port 8000
# terminal 2 — frontend
cd frontend
npm install
npm run dev
# runs on localhost:5173
# calls backend at localhost:8000deployment
STEP 1: deploy backend to Railway
- connect GitHub repo
- add environment variables
- Railway auto-detects FastAPI
- get URL: https://backend-abc.railway.app
STEP 2: set backend URL in frontend
- frontend/.env.production:
VITE_API_URL=https://backend-abc.railway.app
STEP 3: deploy frontend to Vercel
- connect GitHub repo
- add environment variables
- Vercel auto-builds React
- get URL: https://app.abhishekkumar.xyz
STEP 4: update CORS on backend
- add frontend URL to allow_origins
- redeploy backend
common patterns
loading state pattern:
function Page() {
const { data, loading, error } = useFetch('/api/data')
if (loading) return <Skeleton /> // show skeleton while loading
if (error) return <Error /> // show error state
if (!data?.length) return <Empty /> // show empty state
return <DataList data={data} /> // show data
}form submission pattern:
const [status, setStatus] = useState('idle') // idle loading success error
const handleSubmit = async (formData) => {
setStatus('loading')
try {
await api.post('/api/resource', formData)
setStatus('success')
} catch (err) {
setStatus('error')
setError(err.message)
}
}
return (
<button disabled={status === 'loading'}>
{status === 'loading' ? 'Saving...' : 'Save'}
</button>
)=^._.^= frontend shows. backend knows. together they do.