Build a Full-Stack Django Starter Template with PostgreSQL, HTMX, Tailwind, and Alpine.js
What You’ll Build
A production-ready Django starter template featuring:
- Django 6.0 with PostgreSQL database and authentication system
- User management: Registration, login, logout, and profile editing
- Item CRUD: Full create, read, update, delete operations
- HTMX: Server-driven pagination and inline editing
- Alpine.js: Interactive modals and dropdowns
- Tailwind CSS: Modern, responsive styling
- Admin dashboard: Stats and recent items overview
- Railway deployment: One-click production configuration
Prerequisites
Before starting, make sure you have:
- Python 3.11+ installed (download here)
- Git installed and configured
- Git Bash (all codeblocks in this guide are designed to be pasted into a Git Bash terminal)
- GitHub account for code hosting
- Railway account (sign up with our referral link for $20 free credits)
Set Up Project and Database
Create a new directory for your project:
mkdir django-pg-htmxNavigate to the project directory:
cd django-pg-htmxSet up a virtual environment:
python -m venv venvActivate the virtual environment:
On Windows:
source venv/Scripts/activateOn macOS/Linux:
source venv/bin/activateInstall Django and other required packages:
pip install django psycopg2-binary django-htmx whitenoise python-decouple dj-database-url django-tailwind-cli gunicorn pillowCreate a requirements.txt file:
pip freeze > requirements.txtInitialize a new Django project:
django-admin startproject django_pg_htmx .This creates a Django project named django_pg_htmx in the current directory.
Create the apps we’ll need. Create the core app (for main CRUD functionality):
python manage.py startapp coreCreate the accounts app (for authentication and profiles):
python manage.py startapp accountsYour project structure should now look like this:
django-pg-htmx/├── manage.py├── requirements.txt├── venv/├── django_pg_htmx/│ ├── __init__.py│ ├── settings.py│ ├── urls.py│ ├── wsgi.py│ └── asgi.py├── core/│ ├── __init__.py│ ├── admin.py│ ├── apps.py│ ├── models.py│ ├── tests.py│ └── views.py└── accounts/ ├── __init__.py ├── admin.py ├── apps.py ├── models.py ├── tests.py └── views.pySet Up Railway and PostgreSQL
Install Railway CLI
npm install -g @railway/cliRestart your terminal after installation.
Note: Railway CLI requires Node.js. If you don’t have Node.js installed, you can download it from nodejs.org.
Login to Railway
railway loginCreate Railway Project
railway initWhen prompted:
- Select a workspace: Choose your workspace
- Project Name: Empty Project for a randomly generated name or
django-pg-htmx
Add PostgreSQL Database
railway add -d postgresThis creates a PostgreSQL database in your Railway project.
Link Your Local Project to Railway
railway linkWhen prompted, make the following selections:
- Select a workspace: Choose your workspace
- Select a project: Choose the project you just created
- Select an environment: Choose
production - Select a service: Choose
Postgres
Note: If you don’t see “Select a service” appear, you may need to run
railway linkagain to ensure the Postgres service is properly linked.
Configure Django Settings
Remove the default settings file and create a settings directory:
rm django_pg_htmx/settings.pymkdir django_pg_htmx/settingstouch django_pg_htmx/settings/__init__.pyCreate the base settings file:
cat > django_pg_htmx/settings/base.py << 'EOF'"""Base settings for Django project.Common settings shared across all environments."""from pathlib import Pathimport os
# Build paths inside the project like this: BASE_DIR / 'subdir'.BASE_DIR = Path(__file__).resolve().parent.parent.parent
# Application definitionINSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'django_htmx', 'django_tailwind_cli', 'core', 'accounts',]
MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django_htmx.middleware.HtmxMiddleware',]
ROOT_URLCONF = 'django_pg_htmx.urls'
TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, },]
WSGI_APPLICATION = 'django_pg_htmx.wsgi.application'
# Password validationAUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, { 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', }, { 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', }, { 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', },]
# InternationalizationLANGUAGE_CODE = 'en-us'TIME_ZONE = 'UTC'USE_I18N = TrueUSE_TZ = True
# Static files (CSS, JavaScript, Images)STATIC_URL = '/static/'STATIC_ROOT = BASE_DIR / 'staticfiles'STATICFILES_DIRS = [BASE_DIR / 'static']
# Media files (user uploads)MEDIA_URL = '/media/'MEDIA_ROOT = BASE_DIR / 'media'
# Default primary key field typeDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Login/Logout URLsLOGIN_URL = 'accounts:login'LOGIN_REDIRECT_URL = 'core:dashboard'LOGOUT_REDIRECT_URL = 'accounts:login'
# Tailwind CSS configurationTAILWIND_CLI_DIST_CSS = 'css/tailwind.css'EOFCreate the local development settings file:
cat > django_pg_htmx/settings/local.py << 'EOF'"""Local development settings.Uses SQLite for local development without external dependencies."""from .base import *
# SECURITY WARNING: don't run with debug turned on in production!DEBUG = True
# SECURITY WARNING: keep the secret key used in production secret!SECRET_KEY = 'django-insecure-local-dev-key-not-for-production'
# Allowed hosts for local developmentALLOWED_HOSTS = ['localhost', '127.0.0.1']
# Database configuration - SQLite for local developmentDATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', }}EOFCreate the production settings file:
cat > django_pg_htmx/settings/production.py << 'EOF'"""Production settings.Configured for Railway deployment."""from .base import *from decouple import configimport dj_database_url
# SECURITY WARNING: don't run with debug turned on in production!DEBUG = config('DEBUG', default=False, cast=bool)
# SECURITY WARNING: keep the secret key used in production secret!SECRET_KEY = config('SECRET_KEY')
# Database configuration# Uses DATABASE_URL from Railway Postgres pluginDATABASES = { 'default': dj_database_url.config( conn_max_age=600, conn_health_checks=True, )}
# Allowed hostsALLOWED_HOSTS = config('ALLOWED_HOSTS', default='', cast=lambda v: [s.strip() for s in v.split(',') if s.strip()])
# Static files storage (WhiteNoise for production)STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# Trust Railway's proxy headers for SSLSECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Security settings for productionif not DEBUG: SECURE_SSL_REDIRECT = True SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True SECURE_BROWSER_XSS_FILTER = True SECURE_CONTENT_TYPE_NOSNIFF = True X_FRAME_OPTIONS = 'DENY'EOFUpdate manage.py to use local settings by default:
cat > manage.py << 'EOF'#!/usr/bin/env python"""Django's command-line utility for administrative tasks."""import osimport sys
def main(): """Run administrative tasks.""" # Use local settings by default for development os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_pg_htmx.settings.local') try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc execute_from_command_line(sys.argv)
if __name__ == '__main__': main()EOFCreate Models
Update core/models.py to create the Item model:
cat > core/models.py << 'EOF'from django.db import modelsfrom django.contrib.auth.models import Userfrom django.urls import reverse
class Item(models.Model): """Generic item model for demonstrating CRUD operations.""" name = models.CharField(max_length=200) description = models.TextField(blank=True) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='items') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: ordering = ['-created_at'] indexes = [ models.Index(fields=['-created_at']), models.Index(fields=['user', '-created_at']), ]
def __str__(self): return self.name
def get_absolute_url(self): return reverse('core:item_detail', kwargs={'pk': self.pk})EOFCreate accounts/models.py with the Profile model and signals:
cat > accounts/models.py << 'EOF'from django.db import modelsfrom django.contrib.auth.models import Userfrom django.db.models.signals import post_savefrom django.dispatch import receiver
class Profile(models.Model): """User profile model extending the default User model.""" user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') bio = models.TextField(blank=True, max_length=500) avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True)
def __str__(self): return f"{self.user.username}'s Profile"
def get_absolute_url(self): from django.urls import reverse return reverse('accounts:profile')
@receiver(post_save, sender=User)def create_user_profile(sender, instance, created, **kwargs): """Create a profile automatically when a user is created.""" if created: Profile.objects.create(user=instance)
@receiver(post_save, sender=User)def save_user_profile(sender, instance, **kwargs): """Save the profile when the user is saved.""" if hasattr(instance, 'profile'): instance.profile.save()EOFRegister models in admin. Update core/admin.py:
cat > core/admin.py << 'EOF'from django.contrib import adminfrom .models import Item
@admin.register(Item)class ItemAdmin(admin.ModelAdmin): list_display = ['name', 'user', 'created_at', 'updated_at'] list_filter = ['created_at', 'updated_at'] search_fields = ['name', 'description'] date_hierarchy = 'created_at'EOFUpdate accounts/admin.py:
cat > accounts/admin.py << 'EOF'from django.contrib import adminfrom .models import Profile
@admin.register(Profile)class ProfileAdmin(admin.ModelAdmin): list_display = ['user', 'created_at', 'updated_at'] search_fields = ['user__username', 'user__email', 'bio']EOFSet Up Frontend
Create the templates and static directories:
mkdir -p templates static/cssCreate templates/base.html:
cat > templates/base.html << 'EOF'{% load django_htmx tailwind_cli %}<!DOCTYPE html><html lang="en" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block title %}Django Starter{% endblock %}</title> {% tailwind_css %} {% htmx_script %} <script> // Ensure Chart.js is loaded before Alpine initializes (if needed) window.ChartLoaded = true; </script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script></head><body class="bg-gray-50 min-h-screen" x-data="{ open: false, itemId: null }"> <nav class="bg-white shadow-sm border-b"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="flex justify-between h-16"> <div class="flex items-center"> <a href="{% url 'core:dashboard' %}" class="text-xl font-bold text-gray-900"> Django Starter </a> </div> <div class="flex items-center space-x-4"> {% if user.is_authenticated %} <span class="text-gray-700">Hello, {{ user.username }}</span> <a href="{% url 'core:dashboard' %}" class="text-gray-700 hover:text-gray-900">Dashboard</a> <a href="{% url 'core:item_list' %}" class="text-gray-700 hover:text-gray-900">Items</a>
<!-- User Menu Dropdown (Alpine.js) --> <div x-data="{ open: false }" class="relative"> <button @click="open = !open" class="flex items-center text-gray-700 hover:text-gray-900 focus:outline-none"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> </svg> </button> <div x-show="open" @click.away="open = false" x-transition class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50"> <a href="{% url 'accounts:profile' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Profile</a> <a href="{% url 'accounts:logout' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Logout</a> </div> </div> {% else %} <a href="{% url 'accounts:login' %}" class="text-gray-700 hover:text-gray-900">Login</a> <a href="{% url 'accounts:register' %}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">Sign Up</a> {% endif %} </div> </div> </div> </nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> {% if messages %} <div class="mb-4"> {% for message in messages %} <div class="bg-{% if message.tags == 'error' %}red{% elif message.tags == 'success' %}green{% else %}blue{% endif %}-100 border border-{% if message.tags == 'error' %}red{% elif message.tags == 'success' %}green{% else %}blue{% endif %}-400 text-{% if message.tags == 'error' %}red{% elif message.tags == 'success' %}green{% else %}blue{% endif %}-700 px-4 py-3 rounded relative" role="alert"> <span class="block sm:inline">{{ message }}</span> </div> {% endfor %} </div> {% endif %}
{% block content %} {% endblock %} </main>
<!-- Delete Confirmation Modal (Alpine.js) --> <div x-show="open" x-cloak class="fixed inset-0 z-50 overflow-y-auto" style="display: none;" @keydown.escape.window="open = false"> <div class="flex items-center justify-center min-h-screen px-4"> <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" @click="open = false"></div> <div class="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6"> <h3 class="text-lg font-medium text-gray-900 mb-4">Confirm Delete</h3> <p class="text-sm text-gray-500 mb-6">Are you sure you want to delete this item? This action cannot be undone.</p> <div class="flex justify-end space-x-3"> <button @click="open = false" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"> Cancel </button> <form :action="`/items/${itemId}/delete/`" method="post" style="display: inline;"> {% csrf_token %} <button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"> Delete </button> </form> </div> </div> </div> </div>
<style> [x-cloak] { display: none !important; } </style></body></html>EOFCreate Authentication
Create accounts/forms.py:
cat > accounts/forms.py << 'EOF'from django import formsfrom django.contrib.auth.forms import UserCreationFormfrom django.contrib.auth.models import Userfrom .models import Profile
class UserRegistrationForm(UserCreationForm): email = forms.EmailField(required=True)
class Meta: model = User fields = ['username', 'email', 'password1', 'password2']
def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Add Tailwind classes to form fields for field_name, field in self.fields.items(): field.widget.attrs['class'] = 'w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500'
class ProfileForm(forms.ModelForm): class Meta: model = Profile fields = ['bio', 'avatar'] widgets = { 'bio': forms.Textarea(attrs={ 'class': 'w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500', 'rows': 4, }), 'avatar': forms.FileInput(attrs={ 'class': 'w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500', }), }EOFUpdate accounts/views.py:
cat > accounts/views.py << 'EOF'from django.shortcuts import render, redirectfrom django.contrib.auth import loginfrom django.contrib.auth.decorators import login_requiredfrom django.contrib.auth.views import LoginView, LogoutViewfrom django.contrib import messagesfrom django.views.generic import CreateViewfrom django.urls import reverse_lazyfrom .forms import UserRegistrationForm, ProfileFormfrom .models import Profile
class UserRegistrationView(CreateView): form_class = UserRegistrationForm template_name = 'accounts/register.html' success_url = reverse_lazy('core:dashboard')
def form_valid(self, form): response = super().form_valid(form) login(self.request, self.object) messages.success(self.request, 'Registration successful! Welcome!') return response
class UserLoginView(LoginView): template_name = 'accounts/login.html' redirect_authenticated_user = True
def get_success_url(self): return reverse_lazy('core:dashboard')
class UserLogoutView(LogoutView): next_page = 'accounts:login'
def dispatch(self, request, *args, **kwargs): messages.success(request, 'You have been logged out successfully.') return super().dispatch(request, *args, **kwargs)
@login_requireddef profile_view(request): """View user profile.""" profile = request.user.profile return render(request, 'accounts/profile.html', {'profile': profile})
@login_requireddef profile_edit(request): """Edit user profile.""" profile = request.user.profile if request.method == 'POST': form = ProfileForm(request.POST, request.FILES, instance=profile) if form.is_valid(): form.save() messages.success(request, 'Profile updated successfully!') return redirect('accounts:profile') else: form = ProfileForm(instance=profile) return render(request, 'accounts/profile_form.html', {'form': form})EOFCreate the accounts templates directory:
mkdir -p templates/accountsCreate templates/accounts/login.html:
cat > templates/accounts/login.html << 'EOF'{% extends 'base.html' %}
{% block title %}Login - Django Starter{% endblock %}
{% block content %}<div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-6"> <h2 class="text-2xl font-bold text-gray-900 mb-6">Login</h2> <form method="post"> {% csrf_token %} <div class="mb-4"> <label for="id_username" class="block text-sm font-medium text-gray-700 mb-2">Username</label> <input type="text" name="username" id="id_username" required class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"> </div> <div class="mb-4"> <label for="id_password" class="block text-sm font-medium text-gray-700 mb-2">Password</label> <input type="password" name="password" id="id_password" required class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"> </div> <button type="submit" class="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"> Login </button> </form> <p class="mt-4 text-center text-sm text-gray-600"> Don't have an account? <a href="{% url 'accounts:register' %}" class="text-blue-600 hover:text-blue-800">Sign Up</a> </p></div>{% endblock %}EOFCreate templates/accounts/register.html:
cat > templates/accounts/register.html << 'EOF'{% extends 'base.html' %}
{% block title %}Register - Django Starter{% endblock %}
{% block content %}<div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-6"> <h2 class="text-2xl font-bold text-gray-900 mb-6">Sign Up</h2> <form method="post"> {% csrf_token %} {% for field in form %} <div class="mb-4"> <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-700 mb-2"> {{ field.label }} </label> {{ field }} {% if field.errors %} <p class="mt-1 text-sm text-red-600">{{ field.errors.0 }}</p> {% endif %} {% if field.help_text %} <p class="mt-1 text-sm text-gray-500">{{ field.help_text }}</p> {% endif %} </div> {% endfor %} <button type="submit" class="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"> Sign Up </button> </form> <p class="mt-4 text-center text-sm text-gray-600"> Already have an account? <a href="{% url 'accounts:login' %}" class="text-blue-600 hover:text-blue-800">Login</a> </p></div>{% endblock %}EOFCreate templates/accounts/profile.html:
cat > templates/accounts/profile.html << 'EOF'{% extends 'base.html' %}
{% block title %}Profile - Django Starter{% endblock %}
{% block content %}<div class="max-w-2xl mx-auto"> <div class="bg-white rounded-lg shadow-md p-6"> <div class="flex justify-between items-center mb-6"> <h2 class="text-2xl font-bold text-gray-900">Profile</h2> <a href="{% url 'accounts:profile_edit' %}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"> Edit Profile </a> </div> <div class="space-y-4"> <div> <label class="block text-sm font-medium text-gray-700">Username</label> <p class="mt-1 text-gray-900">{{ user.username }}</p> </div> <div> <label class="block text-sm font-medium text-gray-700">Email</label> <p class="mt-1 text-gray-900">{{ user.email }}</p> </div> <div> <label class="block text-sm font-medium text-gray-700">Bio</label> <p class="mt-1 text-gray-900">{{ profile.bio|default:"No bio yet." }}</p> </div> {% if profile.avatar %} <div> <label class="block text-sm font-medium text-gray-700">Avatar</label> <img src="{{ profile.avatar.url }}" alt="Avatar" class="mt-2 h-24 w-24 rounded-full object-cover"> </div> {% endif %} <div> <label class="block text-sm font-medium text-gray-700">Member since</label> <p class="mt-1 text-gray-900">{{ profile.created_at|date:"F d, Y" }}</p> </div> </div> </div></div>{% endblock %}EOFCreate templates/accounts/profile_form.html:
cat > templates/accounts/profile_form.html << 'EOF'{% extends 'base.html' %}
{% block title %}Edit Profile - Django Starter{% endblock %}
{% block content %}<div class="max-w-2xl mx-auto"> <div class="bg-white rounded-lg shadow-md p-6"> <h2 class="text-2xl font-bold text-gray-900 mb-6">Edit Profile</h2> <form method="post" enctype="multipart/form-data"> {% csrf_token %} {% for field in form %} <div class="mb-4"> <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-700 mb-2"> {{ field.label }} </label> {{ field }} {% if field.errors %} <p class="mt-1 text-sm text-red-600">{{ field.errors.0 }}</p> {% endif %} {% if field.help_text %} <p class="mt-1 text-sm text-gray-500">{{ field.help_text }}</p> {% endif %} </div> {% endfor %} <div class="flex space-x-4"> <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"> Save Changes </button> <a href="{% url 'accounts:profile' %}" class="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400"> Cancel </a> </div> </form> </div></div>{% endblock %}EOFCreate accounts/urls.py:
cat > accounts/urls.py << 'EOF'from django.urls import pathfrom . import views
app_name = 'accounts'
urlpatterns = [ path('register/', views.UserRegistrationView.as_view(), name='register'), path('login/', views.UserLoginView.as_view(), name='login'), path('logout/', views.UserLogoutView.as_view(), name='logout'), path('profile/', views.profile_view, name='profile'), path('profile/edit/', views.profile_edit, name='profile_edit'),]EOFCreate Core Application
Create core/forms.py:
cat > core/forms.py << 'EOF'from django import formsfrom .models import Item
class ItemForm(forms.ModelForm): class Meta: model = Item fields = ['name', 'description'] widgets = { 'name': forms.TextInput(attrs={ 'class': 'w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500', }), 'description': forms.Textarea(attrs={ 'class': 'w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500', 'rows': 4, }), }EOFCreate core/urls.py:
cat > core/urls.py << 'EOF'from django.urls import pathfrom . import views
app_name = 'core'
urlpatterns = [ path('', views.dashboard, name='dashboard'), path('items/', views.ItemListView.as_view(), name='item_list'), path('items/create/', views.ItemCreateView.as_view(), name='item_create'), path('items/<int:pk>/', views.item_detail, name='item_detail'), path('items/<int:pk>/edit/', views.ItemUpdateView.as_view(), name='item_update'), path('items/<int:pk>/delete/', views.ItemDeleteView.as_view(), name='item_delete'),]EOFUpdate django_pg_htmx/urls.py:
cat > django_pg_htmx/urls.py << 'EOF'from django.contrib import adminfrom django.urls import path, includefrom django.conf import settingsfrom django.conf.urls.static import static
urlpatterns = [ path('admin/', admin.site.urls), path('accounts/', include('accounts.urls')), path('', include('core.urls')),]
# Serve media files in developmentif settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)EOFCreate templates/dashboard.html:
cat > templates/dashboard.html << 'EOF'{% extends 'base.html' %}
{% block title %}Dashboard - Django Starter{% endblock %}
{% block content %}<div class="space-y-6"> <div class="flex justify-between items-center"> <h1 class="text-3xl font-bold text-gray-900">Dashboard</h1> <a href="{% url 'core:item_create' %}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"> Create Item </a> </div>
<!-- Stats Cards --> <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="bg-white rounded-lg shadow p-6"> <h3 class="text-sm font-medium text-gray-500">Total Items</h3> <p class="text-2xl font-bold text-gray-900 mt-2">{{ total_items }}</p> </div> <div class="bg-white rounded-lg shadow p-6"> <h3 class="text-sm font-medium text-gray-500">Recent Items</h3> <p class="text-2xl font-bold text-gray-900 mt-2">{{ recent_items|length }}</p> </div> <div class="bg-white rounded-lg shadow p-6"> <h3 class="text-sm font-medium text-gray-500">Quick Actions</h3> <div class="mt-2 space-x-2"> <a href="{% url 'core:item_create' %}" class="text-blue-600 hover:text-blue-800 text-sm">Create Item</a> <span class="text-gray-300">|</span> <a href="{% url 'core:item_list' %}" class="text-blue-600 hover:text-blue-800 text-sm">View All</a> </div> </div> </div>
<!-- Recent Items --> <div class="bg-white rounded-lg shadow"> <div class="px-6 py-4 border-b border-gray-200"> <h2 class="text-xl font-bold text-gray-900">Recent Items</h2> </div> <div class="p-6"> {% if recent_items %} <ul class="space-y-3"> {% for item in recent_items %} <li class="flex justify-between items-center py-2 border-b border-gray-200"> <div> <a href="{% url 'core:item_detail' item.pk %}" class="text-blue-600 hover:text-blue-800 font-medium">{{ item.name }}</a> {% if item.description %} <p class="text-sm text-gray-500 mt-1">{{ item.description|truncatewords:20 }}</p> {% endif %} </div> <span class="text-sm text-gray-500">{{ item.created_at|date:"M d, Y" }}</span> </li> {% endfor %} </ul> <div class="mt-4"> <a href="{% url 'core:item_list' %}" class="text-blue-600 hover:text-blue-800">View all items →</a> </div> {% else %} <p class="text-gray-500">No items yet. <a href="{% url 'core:item_create' %}" class="text-blue-600 hover:text-blue-800">Create your first item</a>!</p> {% endif %} </div> </div></div>{% endblock %}EOFCreate the core templates directory:
mkdir -p templates/core templates/core/partialsCreate templates/core/item_list.html:
cat > templates/core/item_list.html << 'EOF'{% extends 'base.html' %}
{% block title %}Items - Django Starter{% endblock %}
{% block content %}<div class="space-y-6"> <div class="flex justify-between items-center"> <h1 class="text-3xl font-bold text-gray-900">Items</h1> <a href="{% url 'core:item_create' %}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"> Create Item </a> </div>
<!-- Items List (HTMX will swap this section) --> <div id="item-list-container"> {% include 'core/partials/item_list_partial.html' %} </div></div>{% endblock %}EOFCreate templates/core/partials/item_list_partial.html:
cat > templates/core/partials/item_list_partial.html << 'EOF'{% if items %} <div class="bg-white rounded-lg shadow overflow-hidden"> <table class="min-w-full divide-y divide-gray-200"> <thead class="bg-gray-50"> <tr> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> {% for item in items %} {% include 'core/partials/item_row.html' %} {% endfor %} </tbody> </table> </div>
<!-- Pagination (HTMX-powered) --> {% if page_obj.has_other_pages %} <div class="mt-4 flex justify-center"> <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination"> {% if page_obj.has_previous %} <a href="?page={{ page_obj.previous_page_number }}" hx-get="?page={{ page_obj.previous_page_number }}" hx-target="#item-list-container" hx-swap="innerHTML" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"> Previous </a> {% endif %}
{% for num in page_obj.paginator.page_range %} {% if page_obj.number == num %} <span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-blue-50 text-sm font-medium text-blue-600"> {{ num }} </span> {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} <a href="?page={{ num }}" hx-get="?page={{ num }}" hx-target="#item-list-container" hx-swap="innerHTML" class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50"> {{ num }} </a> {% endif %} {% endfor %}
{% if page_obj.has_next %} <a href="?page={{ page_obj.next_page_number }}" hx-get="?page={{ page_obj.next_page_number }}" hx-target="#item-list-container" hx-swap="innerHTML" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"> Next </a> {% endif %} </nav> </div> {% endif %}{% else %} <div class="bg-white rounded-lg shadow p-6 text-center"> <p class="text-gray-500">No items yet. <a href="{% url 'core:item_create' %}" class="text-blue-600 hover:text-blue-800">Create your first item</a>!</p> </div>{% endif %}EOFCreate templates/core/partials/item_row.html for the item display:
cat > templates/core/partials/item_row.html << 'EOF'<tr id="item-row-{{ item.pk }}"> <td class="px-6 py-4 whitespace-nowrap"> <div class="text-sm font-medium text-gray-900" id="item-name-{{ item.pk }}">{{ item.name }}</div> </td> <td class="px-6 py-4"> <div class="text-sm text-gray-500" id="item-desc-{{ item.pk }}">{{ item.description|truncatewords:15|default:"—" }}</div> </td> <td class="px-6 py-4 whitespace-nowrap"> <div class="text-sm text-gray-500">{{ item.created_at|date:"M d, Y" }}</div> </td> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> <div class="flex space-x-2"> <a href="{% url 'core:item_detail' item.pk %}" class="text-blue-600 hover:text-blue-900">View</a> <button hx-get="{% url 'core:item_update' item.pk %}" hx-target="#item-row-{{ item.pk }}" hx-swap="outerHTML" class="text-indigo-600 hover:text-indigo-900"> Edit </button> <button @click="open = true; itemId = {{ item.pk }}" class="text-red-600 hover:text-red-900"> Delete </button> </div> </td></tr>EOFCreate a separate template for the edit form row:
cat > templates/core/partials/item_edit_row.html << 'EOF'<tr id="item-row-{{ item.pk }}" class="bg-gray-50"> <td colspan="4" class="px-6 py-4"> <form hx-post="{% url 'core:item_update' item.pk %}" hx-target="#item-row-{{ item.pk }}" hx-swap="outerHTML"> {% csrf_token %} <div class="space-y-3"> <div> <label class="block text-sm font-medium text-gray-700 mb-1">Name</label> <input type="text" name="name" value="{{ item.name }}" required class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"> </div> <div> <label class="block text-sm font-medium text-gray-700 mb-1">Description</label> <textarea name="description" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">{{ item.description }}</textarea> </div> <div class="flex space-x-2"> <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm"> Save </button> <a href="{% url 'core:item_list' %}" class="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 text-sm"> Cancel </a> </div> </div> </form> </td></tr>EOFCreate core/views.py with views for dashboard and CRUD operations:
cat > core/views.py << 'EOF'from django.shortcuts import render, get_object_or_404, redirectfrom django.contrib.auth.decorators import login_requiredfrom django.contrib import messagesfrom django.views.generic import ListView, CreateView, UpdateView, DeleteViewfrom django.urls import reverse_lazyfrom django.core.paginator import Paginatorfrom django.http import JsonResponsefrom .models import Itemfrom .forms import ItemForm
@login_requireddef dashboard(request): """Simple admin dashboard with stats and recent items.""" user_items = Item.objects.filter(user=request.user) total_items = user_items.count() recent_items = user_items[:5]
context = { 'total_items': total_items, 'recent_items': recent_items, } return render(request, 'dashboard.html', context)
class ItemListView(ListView): """List view for items with HTMX pagination support.""" model = Item template_name = 'core/item_list.html' context_object_name = 'items' paginate_by = 10
def get_queryset(self): return Item.objects.filter(user=self.request.user).order_by('-created_at')
def get_template_names(self): """Return different template for HTMX requests.""" if self.request.htmx: return ['core/partials/item_list_partial.html'] return [self.template_name]
class ItemCreateView(CreateView): """Create view for items.""" model = Item form_class = ItemForm template_name = 'core/item_form.html' success_url = reverse_lazy('core:item_list')
def form_valid(self, form): form.instance.user = self.request.user messages.success(self.request, 'Item created successfully!') return super().form_valid(form)
class ItemUpdateView(UpdateView): """Update view for items with HTMX inline editing support.""" model = Item form_class = ItemForm template_name = 'core/item_form.html' success_url = reverse_lazy('core:item_list')
def get_queryset(self): return Item.objects.filter(user=self.request.user)
def form_valid(self, form): messages.success(self.request, 'Item updated successfully!') if self.request.htmx: # Return the updated row for HTMX return render(self.request, 'core/partials/item_row.html', {'item': self.object}) return super().form_valid(form)
def get_template_names(self): """Return different template for HTMX requests.""" if self.request.htmx: if self.request.method == 'GET': # Show edit form return ['core/partials/item_edit_row.html'] else: # Return updated row return ['core/partials/item_row.html'] return [self.template_name]
class ItemDeleteView(DeleteView): """Delete view for items.""" model = Item success_url = reverse_lazy('core:item_list')
def get_queryset(self): return Item.objects.filter(user=self.request.user)
def delete(self, request, *args, **kwargs): messages.success(request, 'Item deleted successfully!') return super().delete(request, *args, **kwargs)
@login_requireddef item_detail(request, pk): """Detail view for a single item.""" item = get_object_or_404(Item, pk=pk, user=request.user) return render(request, 'core/item_detail.html', {'item': item})EOFCreate templates/core/item_detail.html:
cat > templates/core/item_detail.html << 'EOF'{% extends 'base.html' %}
{% block title %}{{ item.name }} - Django Starter{% endblock %}
{% block content %}<div class="max-w-2xl mx-auto"> <div class="bg-white rounded-lg shadow-md p-6"> <div class="flex justify-between items-center mb-6"> <h1 class="text-2xl font-bold text-gray-900">{{ item.name }}</h1> <div class="flex space-x-2"> <a href="{% url 'core:item_update' item.pk %}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"> Edit </a> <button @click="open = true; itemId = {{ item.pk }}" class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700"> Delete </button> </div> </div> <div class="space-y-4"> <div> <label class="block text-sm font-medium text-gray-700">Description</label> <p class="mt-1 text-gray-900">{{ item.description|default:"No description provided." }}</p> </div> <div> <label class="block text-sm font-medium text-gray-700">Created</label> <p class="mt-1 text-gray-900">{{ item.created_at|date:"F d, Y" }}</p> </div> <div> <label class="block text-sm font-medium text-gray-700">Last Updated</label> <p class="mt-1 text-gray-900">{{ item.updated_at|date:"F d, Y" }}</p> </div> </div> <div class="mt-6"> <a href="{% url 'core:item_list' %}" class="text-blue-600 hover:text-blue-800">← Back to Items</a> </div> </div></div>{% endblock %}EOFCreate templates/core/item_form.html:
cat > templates/core/item_form.html << 'EOF'{% extends 'base.html' %}
{% block title %}{% if object %}Edit{% else %}Create{% endif %} Item - Django Starter{% endblock %}
{% block content %}<div class="max-w-2xl mx-auto"> <div class="bg-white rounded-lg shadow-md p-6"> <h2 class="text-2xl font-bold text-gray-900 mb-6">{% if object %}Edit{% else %}Create{% endif %} Item</h2> <form method="post"> {% csrf_token %} {% for field in form %} <div class="mb-4"> <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-700 mb-2"> {{ field.label }} </label> {{ field }} {% if field.errors %} <p class="mt-1 text-sm text-red-600">{{ field.errors.0 }}</p> {% endif %} {% if field.help_text %} <p class="mt-1 text-sm text-gray-500">{{ field.help_text }}</p> {% endif %} </div> {% endfor %} <div class="flex space-x-4"> <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"> {% if object %}Update{% else %}Create{% endif %} </button> <a href="{% url 'core:item_list' %}" class="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400"> Cancel </a> </div> </form> </div></div>{% endblock %}EOFPrepare for Deployment
Create a .gitignore file:
cat > .gitignore << 'EOF'# Python__pycache__/*.py[cod]*$py.class*.so.Pythonvenv/env/ENV/.venv
# Django*.loglocal_settings.pydb.sqlite3db.sqlite3-journal/media/staticfiles
# Environment variables.env.env.local
# IDE.vscode/.idea/*.swp*.swo*~
# OS.DS_StoreThumbs.db
# Tailwind CLI.django_tailwind_cli/EOFCreate a Procfile for Railway deployment:
cat > Procfile << 'EOF'web: mkdir -p staticfiles static/css && python manage.py tailwind build && python manage.py migrate --noinput && python manage.py collectstatic --noinput && gunicorn django_pg_htmx.wsgi:application --bind 0.0.0.0:$PORTEOFThis Procfile runs on each deployment and:
- Creates necessary directories
- Downloads Tailwind CSS CLI and builds CSS (first time or if updated)
- Runs database migrations
- Collects all static files
- Starts the Gunicorn server
Generate Migrations
Generate database migrations locally using SQLite:
python manage.py makemigrations corepython manage.py makemigrations accountsPush to GitHub
Initialize Git repository:
git initgit add .git commit -m "Initial commit: Django starter template"Create GitHub repository and push:
gh repo create --private --source=. --pushPrefer VS Code? Use Source Control panel → Publish to GitHub → Follow prompts
Your complete Django application (including migrations) is now on GitHub and ready for deployment.
Deploy to Railway
Connect GitHub Repository
In Railway dashboard:
- Click your project
- Click “Create”
- Select “GitHub Repo”
- Choose your repository
- Click the “Deploy” button to start the deployment
Generate Domain
- In Railway dashboard, click your django-pg-htmx service
- Go to Settings → Networking
- Click “Generate Domain”
- When prompted “Enter the port your app is listening on”, enter
8080 - Copy the generated domain (e.g.,
django-pg-htmx-production.up.railway.app)
Save this domain - you’ll need it for the ALLOWED_HOSTS configuration.
Link Database to Django Service
Link the PostgreSQL database to your Django service:
- In Railway dashboard, click your django-pg-htmx service
- Go to “Variables” tab
- Click “Add Variable” next to where it says “Trying to connect a database?”
- From the list of available variables, select
DATABASE_URLfrom your Postgres service - Click “Add”
Configure Environment Variables
Add the required environment variables for production:
- In the Variables tab for the django-pg-htmx service, add these variables:
DJANGO_SETTINGS_MODULE:django_pg_htmx.settings.productionSECRET_KEY: Generate a secure secret key by running this command locally:Copy the output and paste it as the value forTerminal window python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"SECRET_KEYin RailwayDEBUG:FalseALLOWED_HOSTS: Use your domain from the previous step (e.g.,django-pg-htmx-production.up.railway.app)
After adding these variables, click the “Deploy” button.
Create Superuser
Create an admin user for your production application.
First, wake up your Railway service by visiting your domain in a browser:
https://your-domain.up.railway.appWait 10-20 seconds for the service to fully start, then SSH into your Django service:
railway ssh --service django-pg-htmxOnce connected, run the createsuperuser command:
python manage.py createsuperuserFollow the prompts to enter:
- Username
- Email address (optional)
- Password (enter twice)
Exit the SSH session:
exitYour superuser is now created in the production database. You can log in at your domain’s /admin/ URL.
Your Django application is now fully deployed and ready to use!
Next Steps
(Optional) Load Sample Data
To see the app with example items, you can load sample data.
Create the fixtures directory:
mkdir -p core/fixturesCreate sample data fixture:
cat > core/fixtures/sample_data.json << 'EOF'[ { "model": "core.item", "pk": 1, "fields": { "name": "Sample Item 1", "description": "This is a sample item to demonstrate the CRUD functionality.", "user": 1, "created_at": "2025-01-15T10:00:00Z", "updated_at": "2025-01-15T10:00:00Z" } }, { "model": "core.item", "pk": 2, "fields": { "name": "Sample Item 2", "description": "Another sample item with some description text.", "user": 1, "created_at": "2025-01-14T10:00:00Z", "updated_at": "2025-01-14T10:00:00Z" } }]EOFCommit and push the fixture file:
git add core/fixtures/sample_data.jsongit commit -m "Add sample data fixture"git pushWait for Railway to complete the automatic deployment (check the deployment status in your Railway dashboard).
Load the sample data into your production database.
SSH into your Django service:
railway ssh --service django-pg-htmxLoad the fixture data:
python manage.py loaddata core/fixtures/sample_data.jsonExit the SSH session:
exitThe sample items will be attached to your superuser account (user ID 1).
To remove sample data later:
SSH into your Django service and run:
python manage.py shell -c "from core.models import Item; Item.objects.all().delete()"Or delete them individually through the web UI at /items/.
Build Your Application
Now that you have a working starter template, you can:
-
Replace or Extend the Item Model: The
Itemmodel is intentionally generic as a demonstration. You can:- Keep it and add your own models alongside it
- Rename it to match your domain (requires updating views, templates, URLs, and forms)
- Remove it entirely and create your own models from scratch
- Use it as a reference pattern for building your own CRUD features
-
Add Your Own Models: Create new models in the
coreapp or create new Django apps -
Customize Templates: Modify the templates to match your design
-
Add More Features: Implement additional functionality as needed
-
Extend HTMX: Add more HTMX-powered features like search, filtering, sorting, etc.
-
Add Alpine.js Components: Create more interactive client-side components
-
Configure Email: Set up email for password resets and notifications
-
Add API Endpoints: Use Django REST Framework if you need a REST API
-
Add Tests: Write unit and integration tests for your models and views
Summary
You’ve successfully created a production-ready Django starter template with:
- ✅ Django 6.0 configured for Railway deployment
- ✅ PostgreSQL database on Railway
- ✅ User authentication and profiles
- ✅ Minimal CRUD operations (Item model)
- ✅ HTMX integration (pagination, inline editing)
- ✅ Alpine.js integration (modals, dropdowns)
- ✅ Tailwind CSS for styling
This template provides a solid foundation for building any Django application with modern tools and best practices.
Don’t have Railway credits yet? Sign up with our referral link to get $20 in credits - enough for a full month on the Pro tier!