A professional networking platform built exclusively for researchers. Think LinkedIn, but stripped down to what academics actually need: discovering PhD openings, posting collaboration requests, connecting with peers across institutions, and messaging without noise.
- Tech Stack
- Project Structure
- Running the Project
- Architecture Deep Dive
- API Reference
- Key Design Decisions
| Layer | Technology | Why |
|---|---|---|
| Backend | Django 5.2 + Django REST Framework | Batteries-included: auth, ORM, admin, migrations — no assembly required |
| Auth | djangorestframework-simplejwt | Stateless JWT tokens, built-in blacklisting for logout |
| Database | PostgreSQL 16 | Relational integrity for social graph; native full-text search |
| Frontend | Next.js 16 (App Router) | SSR for SEO-indexable profiles; React ecosystem |
| Styling | Tailwind CSS + shadcn/ui | Utility-first CSS with pre-built accessible components |
| Server state | TanStack Query (React Query) | Caching, background refetch, infinite scroll, polling |
| Forms | React Hook Form + Zod | Type-safe validation with minimal re-renders |
| HTTP client | Axios | Interceptors for automatic JWT refresh |
academia-connect/
├── backend/ Django project
│ ├── config/
│ │ ├── settings/
│ │ │ ├── base.py Shared settings (all environments)
│ │ │ ├── development.py Local dev overrides (DEBUG, SQLite-free, CORS)
│ │ │ └── production.py Prod overrides (S3, HTTPS cookies, allowed hosts)
│ │ ├── urls.py Root URL dispatcher
│ │ ├── wsgi.py
│ │ └── asgi.py Ready for WebSocket upgrade (Django Channels)
│ ├── apps/
│ │ ├── users/ User model, profiles, publications, research interests
│ │ ├── auth_api/ Register, login, logout, password change
│ │ ├── connections/ Connection requests + unidirectional follows
│ │ ├── opportunities/ Job/collab postings, bookmarks, filters
│ │ ├── feed/ Aggregated opportunity feed (no separate DB table)
│ │ └── messaging/ 1:1 conversations and messages
│ ├── requirements.txt
│ ├── requirements-dev.txt
│ └── .env Environment variables (never commit this)
│
└── frontend/ Next.js project
├── app/
│ ├── (auth)/ Login, register pages (no sidebar layout)
│ └── (main)/ All protected pages (with sidebar layout)
├── components/
│ ├── ui/ shadcn/ui primitives (do not edit manually)
│ ├── layout/ Sidebar, top navigation
│ ├── opportunities/ OpportunityCard, filters
│ ├── connections/ Connection buttons, request cards
│ ├── messaging/ Conversation list, message thread
│ └── common/ UserAvatar, RoleBadge, shared utilities
└── lib/
├── api/ One file per domain (auth, users, opportunities…)
├── providers/ AuthProvider (token state), QueryProvider
├── types/ TypeScript interfaces matching the API
└── utils/ Role colors, opportunity labels, formatting
- Miniconda or Anaconda
- Homebrew (macOS)
- Node.js 18+
git clone <your-repo-url>
cd "academia-connect"Install and start PostgreSQL via Homebrew:
brew install postgresql@16
brew services start postgresql@16Add PostgreSQL to your PATH (add this to ~/.zshrc or ~/.bashrc for persistence):
export PATH="/opt/homebrew/opt/postgresql@16/bin:$PATH"Create the database:
createdb academia_connectCreate and activate the conda environment:
conda create -n connect python=3.11 -y
conda activate connectInstall all backend dependencies:
cd backend
pip install -r requirements-dev.txtCopy the example file and edit it:
cp .env.example .envOpen .env and set these values:
SECRET_KEY=any-long-random-string-here
DJANGO_SETTINGS_MODULE=config.settings.development
DB_NAME=academia_connect
DB_USER=your_macos_username # run: whoami
DB_PASSWORD= # leave blank for Homebrew Postgres
DB_HOST=localhost
DB_PORT=5432Tip: On macOS with Homebrew PostgreSQL,
DB_USERis your system username (output ofwhoami) andDB_PASSWORDis empty.
This creates all tables in PostgreSQL:
# Make sure you are inside the backend/ directory with (connect) active
python manage.py migrate --settings=config.settings.developmentpython manage.py createsuperuser --settings=config.settings.developmentYou will be prompted for email, username, and password. The admin panel is at http://localhost:8000/admin.
python manage.py runserver 8000 --settings=config.settings.developmentThe API is now live at http://localhost:8000/api/v1/.
Open a new terminal tab:
cd frontend
npm installCopy the environment file:
cp .env.local.example .env.local.env.local should contain:
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1Start the dev server:
npm run devThe app is now live at http://localhost:3000.
Open http://localhost:3000/register, pick a role, and create an account. The backend will:
- Hash your password and store the user
- Auto-create a
UserProfilevia a Django signal - Return a JWT access token + refresh token
- Redirect you to
/feed
# Stop Next.js: Ctrl+C in its terminal
# Stop Django: Ctrl+C in its terminal
# Stop PostgreSQL (optional)
brew services stop postgresql@16brew services start postgresql@16
conda activate connect
cd backend && python manage.py runserver 8000 --settings=config.settings.development
# (new tab)
cd frontend && npm run devThe Django project follows a multi-app architecture where each domain of the product is its own Django app inside backend/apps/. Each app owns its models, serializers, views, and URLs — nothing leaks between them except through explicit imports.
apps/users — The foundation. Every other app foreign-keys to User.
User (AbstractUser)
├── email — used as the login identifier (not username)
├── role — professor | phd_student | masters_student |
│ undergraduate | independent_researcher
└── UserProfile (OneToOne)
├── bio, institution, department, location
├── google_scholar_url, orcid_id, linkedin_url
└── research_interests (ManyToMany → ResearchInterest)
ResearchInterest — tag system (e.g. "Machine Learning", "Quantum Computing")
Publication — manually entered papers, linked to User
A Django signal in apps/users/signals.py automatically creates a UserProfile the moment a User is saved for the first time, so the profile always exists and you never need to null-check it.
apps/auth_api — Thin authentication layer on top of simplejwt.
RegisterView: creates the user, returns both tokens immediately so the client is logged in right after signupLogoutView: blacklists the refresh token so it cannot be reused, even if someone has it- JWT tokens are configured in
base.py:- Access token: 15 minutes (short-lived, lives in memory)
- Refresh token: 7 days (long-lived, lives in an
httpOnlycookie)
apps/connections — The social graph. Two separate models with different semantics:
Connection (bidirectional, requires approval)
sender ──FK──▶ User
receiver ──FK──▶ User
status: pending | accepted | rejected
unique_together: (sender, receiver) ← one row per pair, not two
Follow (unidirectional, no approval)
follower ──FK──▶ User
following ──FK──▶ User
unique_together: (follower, following)
Why two models? Connections model mutual academic peers — you send a request, they accept. Follows model lightweight subscriptions — a student can follow a famous professor's posts without the professor needing to do anything. Both contribute to the feed.
The ConnectionStatusView endpoint (GET /connections/status/<user_id>/) computes the relationship between the current user and any other user, returning one of: none, pending_sent, pending_received, connected, following, followed_by, mutual_follow. The frontend uses this single endpoint to decide which buttons to render on a profile page.
apps/opportunities — The core content type.
Opportunity
author ──FK──▶ User
opportunity_type: ra_position | phd_opening | masters_opening |
postdoc | collaboration | project | internship | other
required_role — who can apply (any | specific role)
research_areas ──M2M──▶ ResearchInterest
funding_available, stipend_details, deadline
is_active — soft delete / close an opportunity
OpportunityBookmark
user ──FK──▶ User
opportunity ──FK──▶ Opportunity
unique_together: (user, opportunity) ← one bookmark per pair
Filtering is handled by django-filter via OpportunityFilter in apps/opportunities/filters.py. This maps URL query parameters like ?type=phd_opening&is_remote=true directly to queryset filters without any manual parsing.
apps/feed — No database model. The feed is computed at read time with a single optimized query:
# Pseudocode for how the feed query works
network_ids = (
all users you have an accepted Connection with
UNION
all users you Follow
)
Opportunity.objects
.filter(author_id__in=network_ids, is_active=True)
.select_related('author', 'author__profile') # avoids N+1 on author
.prefetch_related('research_areas', 'bookmarked_by') # avoids N+1 on M2M
.order_by('-created_at')select_related and prefetch_related are critical here — without them, fetching 20 opportunities would fire 60+ database queries. With them, it's always 3 queries regardless of page size.
The feed uses cursor pagination (not page numbers). This means the client gets a cursor token pointing to its position in the result set. If new items are posted while you scroll, you won't see duplicates or skip items.
apps/messaging — 1:1 conversations.
Conversation
participants ──M2M──▶ User (exactly 2 in practice, enforced at view layer)
updated_at (bumped on every new message, used for inbox ordering)
Message
conversation ──FK──▶ Conversation
sender ──FK──▶ User
content
is_read — marked True when the other user fetches the messages
When POST /conversations/ is called with a user_id, the view first checks if a conversation between these two users already exists and returns it if so — you never create duplicates.
Settings are split into three files that inherit from each other:
base.py ← shared across all environments
└── development.py ← DEBUG=True, CORS open to localhost:3000
└── production.py ← secure cookies, S3 storage, allowed hosts from env
The active settings module is selected via the DJANGO_SETTINGS_MODULE environment variable in .env. This means you never touch base.py to switch environments.
The Next.js app uses the App Router with two route groups that share different layouts:
(auth)/ → centered card layout, no sidebar, accessible without login
(main)/ → protected layout with sidebar; redirects to /login if not authenticated
Authentication state lives in AuthProvider (lib/providers/AuthProvider.tsx). The key design choice: the access token is never stored in localStorage (XSS risk). Instead:
- Access token → React state (in-memory, lost on page refresh)
- Refresh token →
httpOnlycookie set by Django (inaccessible to JavaScript)
On every page load, AuthProvider fires a silent POST /auth/token/refresh/ request. The browser automatically sends the refresh cookie, Django validates it and returns a new access token, which gets stored in memory. This is the "silent refresh" pattern — the user stays logged in across browser sessions without any token being readable by JavaScript.
If any API call returns a 401, the Axios interceptor in lib/api/client.ts automatically retries the refresh before propagating the error.
Page loads
│
▼
AuthProvider.useEffect()
│
├── POST /auth/token/refresh/ (cookie sent automatically)
│ │
│ ├── success → setAccessToken(newToken) → fetch /users/me/ → setUser()
│ └── fail → user is null → redirect to /login
│
▼
All API calls attach: Authorization: Bearer <accessToken>
│
└── 401 response → interceptor retries refresh → retry original request
Each domain has its own file in lib/api/:
lib/api/
client.ts → Axios instance (base URL, JWT attach interceptor, refresh-on-401)
auth.ts → register, login, logout, changePassword
users.ts → getMe, updateMe, uploadAvatar, publications, researchInterests
connections.ts → connect, follow, getStatus, listConnections
opportunities.ts → CRUD, filters, bookmarks
feed.ts → getFeed, getDiscover
messaging.ts → conversations, messages, unreadCount
All functions return raw Axios response promises. The consuming hooks (TanStack Query) handle caching, loading states, and error states.
All API data is managed by TanStack Query. Key patterns used:
useQueryfor data that needs to be fetched once and cached (profile, opportunity detail, connection list)useInfiniteQueryfor paginated lists that grow as you scroll (feed, discover)useMutationfor writes (send message, accept connection, bookmark). After a mutation succeeds, related queries are invalidated withqueryClient.invalidateQueries()so the UI updates automaticallyrefetchIntervalfor the message thread (4 seconds) and unread count (15 seconds) — this is the polling strategy for real-time-ish messaging without WebSockets
Here is the complete flow for a typical action — a student sending a connection request to a professor:
1. User clicks "Connect" on /profile/42
2. Frontend
ConnectionButton onClick
→ useMutation calls connectionsApi.sendRequest(42)
→ POST /api/v1/connections/ { receiver_id: 42 }
with Authorization: Bearer <accessToken>
3. Axios interceptor attaches the token, sends request
4. Django
CorsMiddleware: checks Origin header against CORS_ALLOWED_ORIGINS ✓
JWTAuthentication: decodes Bearer token, loads request.user ✓
ConnectionListCreateView.perform_create()
→ checks for existing connection (avoids duplicates)
→ Connection.objects.create(sender=request.user, receiver=user_42, status='pending')
Returns 201 { id: 7, sender: {...}, receiver: {...}, status: 'pending' }
5. Frontend
mutation.onSuccess()
→ queryClient.invalidateQueries(['connection-status', 42])
→ ConnectionStatusView is re-fetched: returns { status: 'pending_sent' }
→ Button re-renders as "Pending" (disabled)
→ toast.success('Connection request sent')
All endpoints require Authorization: Bearer <token> unless marked public.
| Method | Endpoint | Description |
|---|---|---|
| POST | /register/ |
Create account. Returns user + tokens. |
| POST | /login/ |
Returns access + refresh tokens. |
| POST | /token/refresh/ |
Exchange refresh cookie for new access token. |
| POST | /logout/ |
Blacklists the refresh token. |
| POST | /password/change/ |
Change password (requires old password). |
| Method | Endpoint | Description |
|---|---|---|
| GET | /users/ |
List/search users. Supports ?search=, ?role=, ?institution= |
| GET | /users/<id>/ |
Public profile with publications |
| GET | /users/me/ |
Own profile |
| PATCH | /users/me/ |
Update name, profile fields, research interests |
| POST | /users/me/avatar/ |
Upload profile picture (multipart/form-data) |
| GET/POST | /users/me/publications/ |
List or add publications |
| GET/PUT/DELETE | /users/me/publications/<id>/ |
Manage a specific publication |
| GET/POST | /research-interests/ |
Browse or create research interest tags |
| Method | Endpoint | Description |
|---|---|---|
| GET | /connections/ |
My accepted connections |
| POST | /connections/ |
Send request { receiver_id } |
| GET | /connections/requests/ |
Pending requests received |
| GET | /connections/sent/ |
Pending requests I sent |
| PATCH | /connections/<id>/ |
Accept or reject { status: "accepted" } |
| DELETE | /connections/<id>/ |
Remove connection |
| GET | /connections/status/<user_id>/ |
Relationship status with a user |
| GET | /follows/ |
Who I follow |
| GET | /follows/followers/ |
Who follows me |
| POST | /follows/ |
Follow a user { user_id } |
| DELETE | /follows/<id>/ |
Unfollow |
| Method | Endpoint | Description |
|---|---|---|
| GET | /opportunities/ |
Browse all. Filters: ?type=, ?required_role=, ?research_area=, ?is_remote=, ?funding_available=, ?search= |
| POST | /opportunities/ |
Post a new opportunity |
| GET | /opportunities/<id>/ |
Detail view |
| PATCH | /opportunities/<id>/ |
Edit (author only) |
| DELETE | /opportunities/<id>/ |
Delete (author only) |
| GET | /opportunities/my/ |
Opportunities I have posted |
| GET | /bookmarks/ |
My saved opportunities |
| POST | /bookmarks/ |
Save { opportunity_id } |
| DELETE | /bookmarks/<opportunity_id>/ |
Unsave |
| Method | Endpoint | Description |
|---|---|---|
| GET | /feed/ |
Opportunities from connections + follows. Cursor-paginated. |
| GET | /feed/discover/ |
Opportunities outside network matching your research interests |
| Method | Endpoint | Description |
|---|---|---|
| GET | /conversations/ |
My conversations, sorted by most recent message |
| POST | /conversations/ |
Start or retrieve conversation { user_id } |
| GET | /conversations/<id>/ |
Conversation detail + participants |
| GET | /conversations/<id>/messages/ |
Paginated messages (marks unread as read) |
| POST | /conversations/<id>/messages/ |
Send { content } |
| GET | /conversations/unread-count/ |
Total unread badge count |
A pure mutual-connection model (like LinkedIn) forces both parties to act. A pure follow model (like Twitter) has no concept of mutual peers. Academia needs both:
- Connection — when you want to establish a bilateral academic relationship with someone at your level. Requires accept/reject.
- Follow — when a student wants to see a famous professor's posts without expecting reciprocity. Zero friction.
Both contribute to the feed.
The alternative (fanout-on-write) precomputes each user's feed whenever someone posts. This is how Twitter scaled, but it adds infrastructure complexity (background jobs, a fanout table) and is only necessary at very high scale. For a research network, computing the feed on read with an optimized PostgreSQL query and proper indexes is fast enough and simpler to reason about.
WebSockets (Django Channels + Redis + Daphne) is the correct long-term solution. For an MVP it adds: a channel layer, a Redis instance, an ASGI deployment, and a different server setup entirely. The current implementation polls every 4 seconds when the tab is active — invisible to the user at this scale, and the model layer is identical. Upgrading later requires only changing the hooks and adding the Channels config; no model or migration changes.
Any JavaScript that runs on your page can read localStorage. If there is ever an XSS vulnerability anywhere in the app (a third-party script, a user-submitted string that escapes sanitization), an attacker can steal the token and impersonate the user indefinitely. By keeping the access token in memory (a React state variable), it is destroyed when the tab closes and is unreachable to injected scripts. The refresh token in an httpOnly cookie is also unreachable to JavaScript — only the browser sends it, only to the same origin, and only over HTTPS in production.
The DEBUG = True setting must never reach production — it exposes full stack traces and internal state. The split makes this impossible to forget: each environment file only adds or overrides what it needs. The active file is selected by a single environment variable (DJANGO_SETTINGS_MODULE), so switching from dev to prod is one config change, not a code change.