DEV Community

Teemu Virta
Teemu Virta

Posted on

Building a Portfolio Site with Django REST API and Vanilla JavaScript

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)          │        │
│  └─────────────────────────────────────────────────┘        │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/']
Enter fullscreen mode Exit fullscreen mode

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',
    }
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
        });
    });
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }}"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)