Zum Hauptinhalt springen
tsecurity.de LIVE
Echtzeit-Radar & Feeds
Alle RSS Feeds
👥 Community & Social
Unix & Linux ServerSecurity: Mangelnde Eingabeprüfung in perl-Authen-SASL (SUSE)(23.09.2026 um 00:02 Uhr)
Unix & Linux ServerSecurity: Mangelnde Rechteprüfung in Sudo (Ubuntu)(23.09.2026 um 00:02 Uhr)
Sichere ProgrammierungAutomating Bug Reports with Tally and Make(22.09.2026 um 22:44 Uhr)
Sichere ProgrammierungHTB - Forest(22.09.2026 um 22:49 Uhr)
Sichere ProgrammierungYour Ports Are Lying About Your Business(22.09.2026 um 22:52 Uhr)
Sichere ProgrammierungOLTP vs. OLAP: Understanding the Foundation of Modern Data Systems(22.09.2026 um 22:54 Uhr)
Unix & Linux ServerSecurity: Mangelnde Eingabeprüfung in perl-Authen-SASL (SUSE)(23.09.2026 um 00:02 Uhr)
Unix & Linux ServerSecurity: Mangelnde Rechteprüfung in Sudo (Ubuntu)(23.09.2026 um 00:02 Uhr)
Sichere ProgrammierungAutomating Bug Reports with Tally and Make(22.09.2026 um 22:44 Uhr)
Sichere ProgrammierungHTB - Forest(22.09.2026 um 22:49 Uhr)
Sichere ProgrammierungYour Ports Are Lying About Your Business(22.09.2026 um 22:52 Uhr)
Sichere ProgrammierungOLTP vs. OLAP: Understanding the Foundation of Modern Data Systems(22.09.2026 um 22:54 Uhr)
Intelligence View
⚡ tsecurity.de Intelligence

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,…

0
↗ Quelle (dev.to)
Reagiere als Erste:r — dein Feedback zählt!

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/[email protected]
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

Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Building a Portfolio Site with Django REST API and Vanilla JavaScript

Thematisch verwandte Begriffe: Building, Portfolio, Site, with · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Zum Aktualisieren ziehen
ZERO-DAY CVE-2026-82000 | Adobe Experience Manager Forms JEE is affected by a Server-Side Request …
Advisory →
TTS Reader • tsecurity.de Voice
tsecurity.de Icon
tsecurity.de App
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag
Themen-Radar & Intelligence Matrix
Echtzeit-Taxonomie nach Angriffsvektoren & Plattformen

tsecurity.de Live Threat Radar

🔴 LIVE RADAR
MONITORING
AKTIV
CVE-DATENBANK
LIVE
🔍
Community Radar & Live Chat
Sentinel Bot online • Live-Stream
Dein Cluster: Security Explorer
Match:
lädt…
Verbindung zum Community-Stream wird aufgebaut...
Bearbeitungsmodus — Senden überschreibt deine Nachricht
Community-Puls — was gerade passiert
lädt…
Aktivitäten deiner Analysten
lädt…
Neues Thema oder Eilmeldung einreichen

Reiche interessante Links, Zero-Days oder Debatten ein. Die Community entscheidet per Upvote über die Veröffentlichung.

Heiß diskutierte Einreichungen
🔖 Gespeicherte Artikel
📂 Keine gespeicherten Artikel vorhanden.
Zurück Ziehen Vor
Links: vorheriger Artikel Rechts: nächster Artikel unten: schließen
News NIS-2 Frühwarnung Tier-1 Intel ⏱️ 3 Min vor 10 Min
Artikeldaten werden geladen...

Zurück: vorheriger Vor: nächster
↗ Original-Quelle
Social Reaktionen Deine Reaktion zählt
Einstufung & Relevanz-Poll 0 Stimmen
In sozialen Netzwerken teilen 1-Klick