A full-stack blogging platform built with Django REST Framework (backend) and React + Vite (frontend). Users can sign up, log in, create posts, and read posts from other users.
- Features
- Tech Stack
- Architecture Overview
- Project Structure
- Authentication and Security Model
- Data Model
- API Reference
- Request and Response Flows
- Environment Variables
- Local Development
- Deployment
- Known Limitations and Future Work
- User accounts. Sign up and log in with a username and password. Sessions are server side, tracked with a secure HTTP only cookie.
- Create posts. Authenticated users can write a post with a title and body.
- Read posts. Anyone, logged in or not, can browse the feed and open a post to read the full text.
- Full post view. Clicking a post title opens a dedicated detail page instead of a truncated preview.
- Edit posts. Authors can edit their own posts. The same editor UI is reused for both creating and editing, switching modes based on whether an existing post is loaded.
- Delete posts. Authors can delete their own posts, from the feed or from the detail page.
- Ownership enforcement. Editing or deleting a post you do not own is rejected by the backend itself, not just hidden in the UI.
- Responsive, styled UI. No component library. All styling is inline, custom built.
| Layer | Technology |
|---|---|
| Backend framework | Django 6, Django REST Framework |
| Backend auth | Django sessions + CSRF tokens |
| Database | PostgreSQL in production, SQLite for local dev |
| Static files | WhiteNoise |
| WSGI server | Gunicorn |
| Frontend framework | React 19 |
| Build tool | Vite 8 |
| Styling | Inline CSS in JS, no UI library |
| Backend hosting | Render (free tier) |
| Frontend hosting | Vercel |
The app is two separately deployed services that communicate entirely over HTTP. There is no server side rendering and no shared codebase between frontend and backend beyond the JSON contract of the API.
flowchart LR
subgraph Browser
UI[React SPA<br/>Vite build]
end
subgraph Vercel
Static[Static hosting<br/>serves the built React app]
end
subgraph Render
Django[Django + DRF<br/>gunicorn]
DB[(PostgreSQL)]
end
UI -- "loads index.html + JS bundle" --> Static
UI -- "fetch() with credentials: include / JSON over HTTPS" --> Django
Django -- "ORM queries" --> DB
Django -- "Set-Cookie: sessionid, csrftoken" --> UI
Key architectural decisions:
- Cross origin by design. The frontend domain (
*.vercel.app) and backend domain (*.onrender.com) are different origins. This forces explicit CORS and CSRF configuration rather than letting the browser's same origin defaults paper over the problem. - Session auth, not JWT. Login sets a
sessionidcookie. The browser sends it automatically on every request viacredentials: 'include'. This avoids the complexity of token refresh and storage, at the cost of needing correct cross site cookie settings (SameSite=None; Secure) in production. - CSRF token delivered in the JSON body, not read from the cookie. This is the single most important non-obvious design decision in the project, explained in detail below.
- Environment driven settings. The same
settings.pyruns against SQLite locally and PostgreSQL in production, switching automatically based on theDATABASE_URLenvironment variable, with no code change required.
DevBlog/
├── backend/
│ ├── backend/
│ │ ├── settings.py # environment-driven config (dev + prod)
│ │ ├── urls.py # root URL routing, registers the API router
│ │ └── wsgi.py
│ ├── posts/
│ │ ├── models.py # Post model
│ │ ├── serializers.py # PostSerializer
│ │ ├── views.py # PostViewSet + auth views (signup/login/logout/me)
│ │ └── urls.py # legacy, unused; real routing lives in backend/urls.py
│ ├── requirements.txt
│ ├── Procfile # gunicorn start command reference
│ └── runtime.txt
└── frontend/
├── src/
│ ├── App.jsx # top-level state, routing between views, layout
│ ├── lib/
│ │ ├── api.js # fetch wrapper, CSRF token storage
│ │ └── format.js # date/read-time/initials helpers
│ └── components/
│ ├── Avatar.jsx
│ ├── Toast.jsx
│ ├── FeaturedCard.jsx
│ ├── PostCard.jsx
│ ├── AuthForm.jsx
│ ├── Editor.jsx # shared create/edit post form
│ ├── PostDetail.jsx
│ └── Sidebar.jsx
├── index.html
└── vite.config.js
Django's built in session framework is the simplest way to authenticate a small app: log in once, the server remembers you via a signed cookie, every subsequent request is automatically authenticated by the browser attaching that cookie. The cost is that cookies behave differently across origins, and the app deliberately deploys frontend and backend on different origins, so this had to be solved properly rather than avoided.
Django protects state changing requests (POST, PATCH, DELETE) with a CSRF token. The standard pattern is: the server sets a csrftoken cookie, client side JavaScript reads that cookie with document.cookie, and sends its value back in an X-CSRFToken header.
That pattern silently breaks the moment frontend and backend are on different domains. A cookie set by a response from onrender.com is stored under the onrender.com domain. JavaScript running on a page served from vercel.app can never read a cookie that belongs to a different domain, regardless of SameSite or Secure settings. The result was getCsrf() always returning undefined, which gets serialized into the request header as the literal string "undefined", which Django then rejects with a "CSRF token has incorrect length" error, because nine characters is not a valid token.
The fix implemented in this project: the backend's /api/auth/me/ and /api/auth/login/ endpoints return the CSRF token directly in the JSON response body (via Django's get_token(request)), not just as a cookie. The frontend stores that value in an in-memory JavaScript variable (src/lib/api.js) and attaches it manually to the X-CSRFToken header on every mutating request. The cookie is still set (Django requires it to exist server side to validate against), but the frontend never depends on reading it.
sequenceDiagram
participant Browser
participant Django as Django backend
Browser->>Django: GET /api/auth/me/
Django-->>Browser: Set-Cookie: csrftoken=...
Django-->>Browser: Body: { username, csrfToken }
Note over Browser: csrfToken stored in memory,<br/>not read from document.cookie
Browser->>Django: POST /api/posts/ (Header: X-CSRFToken from memory)
Django->>Django: Compares header token against session's expected token
Django-->>Browser: 201 Created
Early in development, the PostViewSet only checked IsAuthenticatedOrReadOnly, meaning any logged in user could technically send a DELETE or PATCH to another user's post ID directly against the API, even though the UI only showed those buttons to the post's actual author. A custom IsAuthorOrReadOnly permission class was added, checked at the object level, so the backend itself rejects the request with 403 Forbidden if request.user does not match post.author. The UI hiding the buttons is a convenience, not the security boundary.
CORS_ALLOWED_ORIGINS = ["https://<your-vercel-app>.vercel.app"]
CORS_ALLOW_CREDENTIALS = True
SESSION_COOKIE_SAMESITE = "None"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = "None"
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = ["https://<your-vercel-app>.vercel.app"]SameSite=None is required for the browser to send the session cookie on a cross site request at all. Secure=True is required alongside it, since SameSite=None cookies are rejected by browsers unless served over HTTPS. Locally, where frontend and backend share localhost, these are relaxed back to SameSite=Lax and Secure=False so cookies work over plain HTTP during development.
erDiagram
USER ||--o{ POST : writes
USER {
int id
string username
string password_hash
}
POST {
int id
string title
text content
int author_id FK
datetime created
}
Post.author is a foreign key to Django's built in User model. PostSerializer exposes author_name (the username) as a read only derived field, so the frontend never has to look up a user by ID separately.
| Method | Endpoint | Auth required | Description |
|---|---|---|---|
| GET | /api/posts/ |
No | List all posts |
| POST | /api/posts/ |
Yes | Create a post, author set from session |
| GET | /api/posts/:id/ |
No | Get a single post |
| PATCH | /api/posts/:id/ |
Yes, owner only | Update a post |
| DELETE | /api/posts/:id/ |
Yes, owner only | Delete a post |
| POST | /api/auth/signup/ |
No | Register a new user |
| POST | /api/auth/login/ |
No | Log in, returns csrfToken |
| POST | /api/auth/logout/ |
Yes | Log out, clears session |
| GET | /api/auth/me/ |
No | Get current user if any, and csrfToken |
All requests are sent with credentials: 'include' from the frontend so the session cookie is always attached once it exists.
sequenceDiagram
participant React
participant Django
React->>Django: GET /api/auth/me/
React->>Django: GET /api/posts/
Django-->>React: { username, csrfToken } or 401
Django-->>React: list of posts
React->>React: store csrfToken in memory, render feed
Both requests fire in parallel on mount using Promise.all. If /auth/me/ returns 401, the user is simply treated as logged out; the posts still load and render, since reading the feed does not require authentication.
sequenceDiagram
participant React
participant Django
participant DB
React->>Django: POST /api/auth/signup/ with username, password
Django->>DB: check username uniqueness
Django->>DB: create_user(), hashes password
Django-->>React: 201 Created
Signup does not automatically log the user in; the frontend's handleAuth function is called for whichever endpoint, signup or login, matches the current view, and only the login response carries a session cookie and CSRF token.
sequenceDiagram
participant React
participant Django
React->>Django: POST /api/auth/login/ with username, password
Django->>Django: authenticate() checks credentials
Django->>Django: login() creates session, sets sessionid cookie
Django-->>React: 200, { username, csrfToken }
React->>React: store csrfToken, set user, redirect to feed
sequenceDiagram
participant React
participant Django
participant DB
React->>Django: POST /api/posts/ with title, content (Header: X-CSRFToken, Cookie: sessionid)
Django->>Django: verify CSRF token against session
Django->>Django: verify IsAuthenticatedOrReadOnly
Django->>DB: insert post, author = request.user
DB-->>Django: new row
Django-->>React: 201, created post
React->>React: prepend post to feed, show toast, redirect to list
sequenceDiagram
participant React
React->>React: user clicks post title
React->>React: set selected post, switch view to detail
No network request is made here. The post object is already in the frontend's posts state from the initial feed load, so opening the detail view is a pure client side state change, not a fetch.
sequenceDiagram
participant React
participant Django
participant DB
React->>React: user clicks Edit, editor pre-filled with title and content
React->>Django: PATCH /api/posts/:id/ with title, content (Header: X-CSRFToken)
Django->>Django: IsAuthorOrReadOnly checks obj.author == request.user
Django->>DB: update post
Django-->>React: 200, updated post
React->>React: replace post in local state, return to detail view
If the requester is not the post's author, the permission check rejects with 403 Forbidden before any database write happens, regardless of what the frontend UI would have allowed.
sequenceDiagram
participant React
participant Django
participant DB
React->>Django: DELETE /api/posts/:id/ (Header: X-CSRFToken)
Django->>Django: IsAuthorOrReadOnly check
Django->>DB: delete post row
Django-->>React: 204 No Content
React->>React: remove post from local state, return to list if it was open
sequenceDiagram
participant React
participant Django
React->>Django: POST /api/auth/logout/ (Header: X-CSRFToken)
Django->>Django: logout() clears session
Django-->>React: 200 OK
React->>React: clear user, return to feed
| Variable | Local default | Production value |
|---|---|---|
| SECRET_KEY | falls back to a hardcoded dev key | long random string, unique per environment |
| DEBUG | True | False |
| ALLOWED_HOSTS | not required | your Render domain, e.g. devblog-backend.onrender.com |
| DATABASE_URL | not required, falls back to SQLite | Postgres connection string |
| CORS_ALLOWED_ORIGINS | http://localhost:5173 | your Vercel URL |
| CSRF_TRUSTED_ORIGINS | http://localhost:5173 | your Vercel URL |
| Variable | Local value | Production value |
|---|---|---|
| VITE_API_URL | http://localhost:8000/api | https://your-render-domain/api |
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserverRuns on http://localhost:8000
cd frontend
npm install
npm run devRuns on http://localhost:5173
- Backend on Render. Deployed from the
backend/root directory. Build command installs dependencies and runscollectstatic. Start command runs migrations then starts gunicorn, since the free tier has no shell access to run one-off management commands manually. - Frontend on Vercel. Deployed from the
frontend/root directory, auto-detected as a Vite project. - Database. PostgreSQL, either Render's own free instance, which expires after 30 days on the free tier, or an external provider such as Neon.
- Both platforms auto-redeploy on every push to main.
- Free tier cold starts. Render's free web service sleeps after 15 minutes of inactivity; the first request afterward can take 30 to 50 seconds.
- No password reset flow. Signup and login only, no email verification or recovery.
- No pagination.
/api/posts/returns the full list; fine at small scale, would need pagination or infinite scroll for a larger dataset. - No comments or likes. The data model is intentionally minimal: a post belongs to a user, nothing else.
- No image uploads. Posts are text only.
- Third party cookie blocking. Some browsers, Safari by default and others with strict tracking protection, may still block cross site cookies entirely regardless of SameSite=None. A production hardened version would likely move to token based auth, such as DRF's TokenAuthentication or JWT, to remove the dependency on cross site cookies altogether.