Building a personal portfolio site is a rite of passage for developers. Instead of reaching for a heavy frontend framework, I decided to keep things simple: Django REST Framework for the backend API, vanilla JavaScript for dynamic content, and CSS variables for theming. Here's how I built teemu.tech.
What You'll Learn
By the end of this article, you'll understand how to:
- Structure a Django project with split settings for development and production
- Build a REST API that serves content to a JavaScript frontend
- Implement a light/dark theme toggle using CSS variables
- Add privacy-friendly analytics with GDPR compliance
- Build a responsive mobile navigation system
- Set up automated deployment with GitHub Actions
Architecture Overview
The site follows an API-driven architecture. Django serves HTML templates as shells, then JavaScript fetches data from REST endpoints to populate the content dynamically.
┌─────────────────────────────────────────────────────────────┐
│ Browser │
├─────────────────────────────────────────────────────────────┤
│ HTML Templates JavaScript (fetch) │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ index.html │───────>│ GET /api/blog/ │ │
│ │ blog.html │ │ GET /api/projects│ │
│ │ etc. │ │ GET /api/settings│ │
│ └─────────────┘ └────────┬─────────┘ │
└──────────────────────────────────┼──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Django Backend │
├─────────────────────────────────────────────────────────────┤
│ Django REST Framework Analytics Middleware │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ /api/blog/ │ │ /api/projects│ │ /api/settings│ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ SQLite (local) / MySQL (production) │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
This approach gives you the best of both worlds: Django handles data, authentication, and admin; JavaScript handles dynamic rendering without page reloads.
Tech Stack
| Layer | Technology |
|---|---|
| Backend | Django 5.x, Django REST Framework |
| Frontend | Vanilla JavaScript, CSS3 with CSS Variables |
| Database | SQLite (local), MySQL (production) |
| Markdown | Python-Markdown with Pygments syntax highlighting |
| Analytics | Custom Django analytics + Plausible (optional) |
| Deployment | PythonAnywhere |
| CI/CD | GitHub Actions |
Project Structure
The project uses Django's app-based organization with four main apps:
teemu_tech/
├── config/ # Project configuration
│ ├── settings/
│ │ ├── base.py # Shared settings
│ │ ├── local.py # Development (DEBUG=True, SQLite)
│ │ └── production.py # Production (DEBUG=False, MySQL)
│ ├── urls.py
│ └── wsgi.py
│
├── apps/
│ ├── portfolio/ # Projects/work items
│ ├── blog/ # Blog posts with markdown
│ ├── core/ # Site settings, frontend routes
│ └── analytics/ # Page views, stats, admin dashboard
│
├── templates/ # Django HTML templates
├── static/ # CSS, JS, images
└── manage.py
Split Settings Pattern
Instead of one monolithic settings file, I split configuration across three files. The base file contains shared settings:
# config/settings/base.py
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
INSTALLED_APPS = [
'django.contrib.admin',
# ... django apps
'rest_framework',
'apps.portfolio',
'apps.core',
'apps.blog',
'apps.analytics',
]
MIDDLEWARE = [
# ... standard middleware
'apps.analytics.middleware.AnalyticsMiddleware', # Track page views
]
# Analytics settings
ANALYTICS_ENABLED = True
ANALYTICS_EXCLUDE_PATHS = ['/admin/', '/api/', '/static/', '/media/']
Local development extends base with debug settings:
# config/settings/local.py
from .base import *
SECRET_KEY = 'django-insecure-dev-key-change-in-production'
DEBUG = True
ALLOWED_HOSTS = ['localhost', '127.0.0.1']
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
Production adds security headers, MySQL, and reads secrets from environment variables:
# config/settings/production.py
from .base import *
import os
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
DEBUG = False
ALLOWED_HOSTS = ['teemu.tech', 'www.teemu.tech']
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DB_NAME'),
'USER': os.environ.get('DB_USER'),
'PASSWORD': os.environ.get('DB_PASSWORD'),
'HOST': os.environ.get('DB_HOST'),
}
}
# Security settings
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Plausible Analytics (privacy-friendly external service)
PLAUSIBLE_DOMAIN = 'teemu.tech'
When running commands, specify which settings to use:
# Development
python manage.py runserver --settings=config.settings.local
# Production
python manage.py migrate --settings=config.settings.production
Building the REST API
Models
The site has several main models. Here's the BlogPost model with markdown support and view tracking:
# apps/blog/models.py
from django.db import models
import markdown
class BlogPost(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
content = models.TextField(help_text="Markdown content")
tags = models.CharField(max_length=200, blank=True)
is_published = models.BooleanField(default=False, db_index=True)
published_at = models.DateTimeField(blank=True, null=True, db_index=True)
view_count = models.PositiveIntegerField(default=0, db_index=True)
@property
def content_html(self):
"""Render markdown to HTML with syntax highlighting."""
md = markdown.Markdown(extensions=[
'fenced_code',
'codehilite',
'tables',
])
return md.convert(self.content)
Serializers
I use separate serializers for list and detail views to control what data gets sent:
# apps/blog/serializers.py
from rest_framework import serializers
from .models import BlogPost
class BlogPostListSerializer(serializers.ModelSerializer):
"""Minimal data for listing pages."""
tags = serializers.ListField(source='tags_list', read_only=True)
class Meta:
model = BlogPost
fields = ['id', 'title', 'slug', 'excerpt', 'tags', 'published_at']
class BlogPostDetailSerializer(serializers.ModelSerializer):
"""Full data including rendered HTML and view count."""
content_html = serializers.ReadOnlyField()
class Meta:
model = BlogPost
fields = ['id', 'title', 'slug', 'content_html', 'tags',
'published_at', 'view_count']
Views with View Count Tracking
The detail view increments view counts with session-based duplicate prevention:
# apps/blog/views.py
from django.db.models import F
from rest_framework import generics
from .models import BlogPost
from .serializers import BlogPostListSerializer, BlogPostDetailSerializer
class BlogPostDetailView(generics.RetrieveAPIView):
serializer_class = BlogPostDetailSerializer
lookup_field = 'slug'
def get_queryset(self):
return BlogPost.objects.filter(is_published=True)
def retrieve(self, request, *args, **kwargs):
"""Override to increment view count with duplicate prevention."""
response = super().retrieve(request, *args, **kwargs)
instance = self.get_object()
# Session-based duplicate prevention
viewed_posts = request.session.get('viewed_posts', [])
if instance.pk not in viewed_posts:
# Increment using F() to avoid race conditions
BlogPost.objects.filter(pk=instance.pk).update(
view_count=F('view_count') + 1
)
viewed_posts.append(instance.pk)
request.session['viewed_posts'] = viewed_posts
return response
Privacy-Friendly Analytics
I built a custom analytics system that respects user privacy while providing useful insights.
The Analytics Models
# apps/analytics/models.py
from django.db import models
from django.utils import timezone
class PageView(models.Model):
"""Individual page view with anonymized data."""
path = models.CharField(max_length=500, db_index=True)
timestamp = models.DateTimeField(default=timezone.now, db_index=True)
ip_hash = models.CharField(max_length=64, blank=True) # Hashed, not raw IP
session_id = models.CharField(max_length=64, blank=True, db_index=True)
user_agent = models.CharField(max_length=500, blank=True)
referrer = models.URLField(max_length=500, blank=True)
class DailyStats(models.Model):
"""Aggregated daily statistics for performance."""
date = models.DateField(unique=True, db_index=True)
total_views = models.PositiveIntegerField(default=0)
unique_visitors = models.PositiveIntegerField(default=0)
top_pages = models.JSONField(default=list)
top_referrers = models.JSONField(default=list)
class ContentStats(models.Model):
"""View statistics for specific content."""
content_type = models.CharField(max_length=20) # 'blog' or 'project'
content_id = models.PositiveIntegerField()
content_title = models.CharField(max_length=200)
view_count = models.PositiveIntegerField(default=0)
last_viewed = models.DateTimeField(blank=True, null=True)
Middleware for Automatic Tracking
The middleware automatically tracks page views while filtering out bots and static files:
# apps/analytics/middleware.py
from django.utils.deprecation import MiddlewareMixin
from .models import PageView
from .utils import get_client_ip, hash_ip, should_track_request
class AnalyticsMiddleware(MiddlewareMixin):
def process_response(self, request, response):
# Only track successful GET requests
if request.method != 'GET' or not (200 <= response.status_code < 300):
return response
if not should_track_request(request):
return response
# Hash IP with daily salt for privacy
client_ip = get_client_ip(request)
ip_hash = hash_ip(client_ip)
PageView.objects.create(
path=request.path,
ip_hash=ip_hash,
session_id=request.session.session_key or '',
user_agent=request.META.get('HTTP_USER_AGENT', '')[:500],
referrer=request.META.get('HTTP_REFERER', '')[:500],
)
return response
Privacy-First IP Hashing
IPs are hashed with a daily salt, making long-term tracking impossible while allowing same-day unique visitor detection:
# apps/analytics/utils.py
import hashlib
from datetime import date
from django.conf import settings
def get_daily_salt():
"""Generate a daily salt for IP hashing."""
secret = settings.SECRET_KEY[:20]
today = date.today().isoformat()
return f"{secret}-{today}"
def hash_ip(ip_address):
"""Hash IP with daily salt for privacy."""
if not ip_address:
return ''
salt = get_daily_salt()
combined = f"{ip_address}-{salt}"
return hashlib.sha256(combined.encode()).hexdigest()
Bot Detection
The system filters out common crawlers and bots:
BOT_PATTERNS = [
r'bot', r'crawler', r'spider', r'googlebot', r'bingbot',
r'curl', r'wget', r'python-requests', r'headless',
]
def is_bot(user_agent):
if not user_agent:
return True
return any(re.search(p, user_agent, re.I) for p in BOT_PATTERNS)
Admin Dashboard
The analytics app includes a custom admin dashboard with Chart.js visualizations showing daily views, top pages, and top referrers.
Mobile Navigation
The site features an off-canvas mobile menu that slides in from the right:
HTML Structure
<nav class="nav">
<a href="/" class="nav-logo">TEEMU VIRTA</a>
<button class="nav-toggle" onclick="toggleMobileMenu()">
<span></span>
<span></span>
<span></span>
</button>
<div class="nav-links">
<a href="/#skills">Skills</a>
<a href="/#blog">Blog</a>
<a href="/#about">About</a>
</div>
<button class="theme-toggle" onclick="toggleTheme()">...</button>
</nav>
<div class="nav-overlay" onclick="closeMobileMenu()"></div>
CSS for the Slide-in Menu
@media (max-width: 768px) {
.nav-toggle {
display: flex;
}
.nav-links {
position: fixed;
top: 0;
right: 0;
width: 280px;
height: 100vh;
background: var(--bg-secondary);
flex-direction: column;
padding: 80px 30px;
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 99;
}
.nav-links.active {
transform: translateX(0);
}
.nav-overlay.active {
display: block;
opacity: 1;
}
body.menu-open {
overflow: hidden;
}
}
JavaScript for Menu Control
function toggleMobileMenu() {
const navToggle = document.querySelector('.nav-toggle');
const navLinks = document.querySelector('.nav-links');
const navOverlay = document.querySelector('.nav-overlay');
navToggle.classList.toggle('active');
navLinks.classList.toggle('active');
navOverlay.classList.toggle('active');
document.body.classList.toggle('menu-open');
}
function closeMobileMenu() {
// Remove all active classes
document.querySelector('.nav-toggle').classList.remove('active');
document.querySelector('.nav-links').classList.remove('active');
document.querySelector('.nav-overlay').classList.remove('active');
document.body.classList.remove('menu-open');
}
// Close menu on scroll
window.addEventListener('scroll', function() {
if (document.body.classList.contains('menu-open')) {
closeMobileMenu();
}
}, { passive: true });
Frontend: Dynamic Content Loading
The HTML templates are shells that JavaScript populates. Here's how the blog section works:
<!-- templates/index.html -->
<section id="blog" class="blog-section">
<div class="blog-grid" id="blog-grid">
<div class="loading">Loading articles...</div>
</div>
</section>
JavaScript fetches from the API and renders the content:
// Fetch blog posts and render cards
fetch('/api/blog/')
.then(response => response.json())
.then(posts => {
const grid = document.getElementById('blog-grid');
grid.innerHTML = '';
posts.slice(0, 3).forEach(post => {
const card = document.createElement('a');
card.href = `/blog/${post.slug}/`;
card.className = 'blog-card';
// Format the date
const date = new Date(post.published_at);
const formattedDate = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
card.innerHTML = `
<p class="blog-card-date">${formattedDate}</p>
<h3 class="blog-card-title">${post.title}</h3>
<p class="blog-card-excerpt">${post.excerpt || ''}</p>
`;
grid.appendChild(card);
});
});
Light/Dark Theme Toggle
The theme system uses CSS variables. Define colors once, override them for light mode:
/* static/css/style.css */
:root {
--bg-primary: #0a0a0a;
--bg-secondary: #111111;
--text-primary: #ffffff;
--text-secondary: #888888;
--accent: #00d9ff;
}
/* Light theme overrides */
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--accent: #0099cc;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
}
The toggle function switches themes and persists the choice:
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
To prevent a flash of the wrong theme on page load, add this script in the <head> before CSS loads:
<script>
(function() {
const theme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
Automated Deployment with GitHub Actions
Every push to main triggers automatic deployment to PythonAnywhere:
# .github/workflows/deploy.yml
name: Deploy to PythonAnywhere
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.0
with:
host: ssh.pythonanywhere.com
username: ${{ secrets.PYTHONANYWHERE_USERNAME }}
password: ${{ secrets.PYTHONANYWHERE_PASSWORD }}
script: |
cd ~/teemu_tech
source ~/.virtualenvs/teemu_tech_env/bin/activate
git pull origin main
pip install -r requirements.txt
python manage.py migrate --settings=config.settings.production
python manage.py collectstatic --noinput --settings=config.settings.production
- name: Reload web app
run: |
curl -X POST \
https://www.pythonanywhere.com/api/v0/user/${{ secrets.PYTHONANYWHERE_USERNAME }}/webapps/${{ secrets.PYTHONANYWHERE_USERNAME }}.pythonanywhere.com/reload/ \
-H "Authorization: Token ${{ secrets.PYTHONANYWHERE_API_TOKEN }}"
Scheduled Tasks for Analytics
PythonAnywhere scheduled tasks handle daily analytics maintenance:
# Aggregate daily stats (run at 00:15 UTC)
cd ~/teemu_tech && source ~/.virtualenvs/teemu_tech_env/bin/activate && \
python manage.py aggregate_daily_stats --settings=config.settings.production
# Cleanup old data after 90 days (run at 00:30 UTC)
cd ~/teemu_tech && source ~/.virtualenvs/teemu_tech_env/bin/activate && \
python manage.py cleanup_analytics --settings=config.settings.production
Key Takeaways
1. You don't always need a frontend framework. For a portfolio site, vanilla JavaScript with fetch() is plenty. The bundle size is zero, and there's no build step.
2. Split settings keep environments clean. Having separate files for local and production prevents accidentally running debug mode in production.
3. API-driven architecture adds flexibility. The same API could power a mobile app or be consumed by other services. Content changes in Django admin appear instantly on the frontend.
4. CSS variables make theming simple. Define your colors once, override them with a data attribute, and the entire site responds.
5. Privacy-friendly analytics is achievable. You can track useful metrics without storing personal data. Hash IPs with daily salts, filter bots, and aggregate data for insights.
6. GitHub Actions + PythonAnywhere = free CI/CD. Push to main, grab a coffee, and your changes are live.
Conclusion
Building teemu.tech taught me that simplicity often wins. Django's admin handles content management, REST Framework serves data efficiently, and vanilla JavaScript renders it without the overhead of a framework. The custom analytics system provides useful insights while respecting user privacy.
If you're building a portfolio or blog, consider this stack. It's powerful enough for production use, simple enough to understand completely, and flexible enough to grow with your needs.
Tutorial by Teemu Virta - teemu.tech
Top comments (0)