
Development · · 5 min read
How I Structure React + Django Projects
- React
- Django
- Architecture
- TypeScript
- Tutorial
Folder structure feels like bikeshedding until month four, when nobody can find anything and every change touches six unrelated files. This is the React and Django layout I keep coming back to, and why.
I've built enough products on React and Django now, NovaRestro included, to have opinions about where code should live. Not universal truths. Just a structure that has survived growing teams, interns joining mid-project, and features that turned out three times bigger than planned.
The repository: two apps, one repo
I keep frontend and backend in one repository with two top-level folders.
project/
backend/ Django project
frontend/ React (Vite) or Next.js app
docs/ decisions, API notes, permission matrix
.github/ CI workflows
docker-compose.yml local Postgres (and anything else needed)
README.mdOne repo means one pull request can change an API and the screen that uses it. Reviews make more sense, and nobody has to coordinate releases across two repos for a renamed field. The two apps still deploy separately: the frontend to Vercel or as static files behind Nginx, the backend to a VPS with Gunicorn.
Backend: group by domain, not by type
Django's default encourages one models.py, one views.py, one serializers.py per app. That's fine. The question is what an "app" is.
My rule: an app is a business domain. Not api, not core with everything in it.
backend/
config/
settings/
base.py
local.py
production.py
urls.py
wsgi.py
apps/
accounts/ users, roles, permissions
menu/ categories, menu items, modifiers
orders/ orders, order items, kitchen tickets
billing/ bills, splits, payments
inventory/ stock items, movements
common/ base models, pagination, shared utilities
manage.pyEach domain app looks the same inside, which matters more than any individual choice:
apps/orders/
models.py
serializers.py
views.py
urls.py
services.py business logic: create_order, send_to_kitchen, void_item
selectors.py read queries: orders_for_table, open_tickets_for_station
permissions.py
tests/Services and selectors
This is the part I'd push hardest on. Views should be thin. Business rules go into services.py as plain functions; complex reads go into selectors.py.
# apps/orders/services.py
from django.db import transaction
@transaction.atomic
def send_to_kitchen(*, order: Order, user: User) -> list[KitchenTicket]:
ensure_can(user, "orders.send")
pending = order.items.select_for_update().filter(status=OrderItem.Status.PENDING)
if not pending.exists():
raise ValidationError("Nothing new to send.")
tickets = []
for station_id, items in group_by_station(pending):
ticket = KitchenTicket.objects.create(order=order, station_id=station_id)
ticket.items.set(items)
tickets.append(ticket)
pending.update(status=OrderItem.Status.SENT)
return ticketsThe view just calls it:
class SendToKitchenView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, pk):
order = get_object_or_404(Order, pk=pk, restaurant=request.user.restaurant)
tickets = send_to_kitchen(order=order, user=request.user)
return Response(KitchenTicketSerializer(tickets, many=True).data, status=201)Why bother? Because the same rule gets called from the API, from an admin action, from a management command and from tests. If the logic lives in the view, you end up copying it. If it lives in a service, you call it.
Settings split by environment
base.py holds everything shared, local.py and production.py override what differs. Secrets come from environment variables, never from the repo. DEBUG defaults to False and is only turned on in local.py, so forgetting to set it can't expose a production stack trace.
Frontend: group by feature
The frontend follows the same idea. Instead of components/, hooks/, services/ folders that each contain a bit of every feature, I group by feature.
frontend/src/
app/ routes (Next.js App Router) or router setup
components/
ui/ Button, Input, Dialog: no business logic
features/
orders/
api.ts fetch functions + query keys
hooks.ts useOrders, useSendToKitchen
components/ OrderCard, OrderList, SendButton
types.ts
billing/
menu/
lib/
api-client.ts fetch wrapper: base URL, auth, error shape
permissions.ts
styles/The test is simple: if I delete features/billing, does anything outside it break that shouldn't? Ideally only the routes that render billing screens.
One API client, typed responses
All requests go through one small wrapper. It sets the base URL, attaches auth, and turns Django REST Framework errors into one predictable shape the UI can display.
// lib/api-client.ts
export class ApiError extends Error {
constructor(public status: number, public fields: Record<string, string[]> = {}) {
super(`Request failed with ${status}`);
}
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${path}`, {
...init,
credentials: "include",
headers: { "Content-Type": "application/json", ...init.headers },
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(response.status, body);
}
return response.status === 204 ? (undefined as T) : response.json();
}Server state lives in TanStack Query
Each feature's api.ts exports query keys and fetchers; hooks.ts wraps them. Components never call fetch directly.
// features/orders/hooks.ts
export const orderKeys = {
all: ["orders"] as const,
detail: (id: string) => ["orders", id] as const,
};
export function useSendToKitchen(orderId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api<KitchenTicket[]>(`/orders/${orderId}/send/`, { method: "POST" }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: orderKeys.detail(orderId) }),
});
}Query keys in one place means invalidation is predictable. When a real-time event says an order changed, I invalidate orderKeys.detail(id) and every screen showing it refreshes.
Keeping the two sides in sync
The contract between React and Django is the API. I keep it honest in two ways.
- Types generated from the schema. DRF can publish an OpenAPI schema; I generate TypeScript types from it so a renamed field fails the frontend build instead of failing in production.
- Field names stay snake_case on the wire. I used to convert to camelCase in the client. It added a layer of bugs for no real gain, so I stopped.
Authentication
For browser apps on the same parent domain, I prefer Django session auth with HTTP-only cookies and CSRF protection over storing tokens in localStorage. It's less code and a smaller attack surface. For mobile clients like a Flutter app, token auth makes more sense. The Django security docs are worth reading once end to end.
Mistakes that shaped this
- A giant `core` app. It became the place where code went to be forgotten.
- Logic in serializers.
create()methods that sent emails and adjusted stock were impossible to reuse. - Fetching in components. Every screen had its own loading logic and its own bugs.
- Separate repos too early. Every API change needed two PRs and a coordination message.
Structure should make the right thing the easy thing. If adding a feature means touching one folder on each side, you've got it about right.
The short version
Organise both sides around business domains. Keep Django views thin and put rules in services. Keep React components dumb and put server state in TanStack Query hooks. Generate types from the API so the contract can't silently drift. It's the same idea twice: put things where the next person will look for them. If you want to see how this plays out in a real product, the NovaRestro write-up walks through the domain decisions that sit inside these folders.
