diff --git a/www/conservancy/settings.py b/www/conservancy/settings.py
index 2f26462e..3fd906f8 100644
--- a/www/conservancy/settings.py
+++ b/www/conservancy/settings.py
@@ -100,7 +100,7 @@ INSTALLED_APPS = [
     'conservancy.apps.assignment',
     'conservancy.apps.fossy',
     'podjango',
-    'usethesource',
+    'usethesource.apps.UseTheSourceConfig',
 ]
 
 DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
diff --git a/www/usethesource/admin.py b/www/usethesource/admin.py
new file mode 100644
index 00000000..ae5df7f3
--- /dev/null
+++ b/www/usethesource/admin.py
@@ -0,0 +1,26 @@
+from django.contrib import admin
+
+from .models import Candidate, Comment
+
+
+class CommentInline(admin.TabularInline):
+    model = Comment
+    fields = ['user', 'message']
+    extra = 0
+
+
+@admin.register(Candidate)
+class CandidateAdmin(admin.ModelAdmin):
+    list_display = ['name', 'vendor', 'device', 'release_date']
+    fields = [
+        'name',
+        'slug',
+        'vendor',
+        'device',
+        'release_date',
+        'source_url',
+        'binary_url',
+        'description',
+    ]
+    inlines = [CommentInline]
+    prepopulated_fields = {'slug': ['name']}
diff --git a/www/usethesource/apps.py b/www/usethesource/apps.py
new file mode 100644
index 00000000..e7322c9a
--- /dev/null
+++ b/www/usethesource/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class UseTheSourceConfig(AppConfig):
+    default_auto_field = 'django.db.models.AutoField'
+    name = 'usethesource'
diff --git a/www/usethesource/migrations/0001_initial.py b/www/usethesource/migrations/0001_initial.py
new file mode 100644
index 00000000..81a4958e
--- /dev/null
+++ b/www/usethesource/migrations/0001_initial.py
@@ -0,0 +1,44 @@
+# Generated by Django 3.2.19 on 2023-10-24 08:53
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Candidate',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=50, verbose_name='Candidate name')),
+                ('slug', models.SlugField(unique=True)),
+                ('vendor', models.CharField(max_length=50, verbose_name='Vendor name')),
+                ('device', models.CharField(max_length=50, verbose_name='Device name')),
+                ('release_date', models.DateField()),
+                ('description', models.TextField()),
+                ('source_url', models.URLField()),
+                ('binary_url', models.URLField(blank=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Comment',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('time', models.DateTimeField(auto_now_add=True)),
+                ('message', models.TextField()),
+                ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='usethesource.candidate')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'ordering': ['id'],
+            },
+        ),
+    ]
diff --git a/www/usethesource/migrations/__init__.py b/www/usethesource/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/www/usethesource/models.py b/www/usethesource/models.py
new file mode 100644
index 00000000..743efd6a
--- /dev/null
+++ b/www/usethesource/models.py
@@ -0,0 +1,29 @@
+from django.contrib.auth.models import User
+from django.db import models
+
+
+class Candidate(models.Model):
+    name = models.CharField('Candidate name', max_length=50)
+    slug = models.SlugField(max_length=50, unique=True)
+    vendor = models.CharField('Vendor name', max_length=50)
+    device = models.CharField('Device name', max_length=50)
+    release_date = models.DateField()
+    description = models.TextField()
+    source_url = models.URLField()
+    binary_url = models.URLField(blank=True)
+
+    def __str__(self):
+        return self.name
+
+
+class Comment(models.Model):
+    candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE)
+    user = models.ForeignKey(User, on_delete=models.PROTECT)
+    time = models.DateTimeField(auto_now_add=True)
+    message = models.TextField()
+
+    def __str__(self):
+        return f'{self.candidate.name}, {self.user}, {self.time}'
+
+    class Meta:
+        ordering = ['id']
diff --git a/www/usethesource/templates/usethesource/add_comment_button_partial.html b/www/usethesource/templates/usethesource/add_comment_button_partial.html
new file mode 100644
index 00000000..1a370e98
--- /dev/null
+++ b/www/usethesource/templates/usethesource/add_comment_button_partial.html
@@ -0,0 +1,3 @@
+<div class="mt2" hx-target="this" hx-swap="outerHTML">
+  <button type="submit" class="f3 b white bg-green ph2" style="border: none" hx-get="{% url 'usethesource:add_comment' slug=candidate.slug %}">+</button>
+</div>
diff --git a/www/usethesource/templates/usethesource/cancel_button_partial.html b/www/usethesource/templates/usethesource/cancel_button_partial.html
new file mode 100644
index 00000000..a8dd444b
--- /dev/null
+++ b/www/usethesource/templates/usethesource/cancel_button_partial.html
@@ -0,0 +1 @@
+<button type="submit" class="b white bg-light-silver pv2 ph3" style="border: none">Cancel</button>
diff --git a/www/usethesource/templates/usethesource/candidate.html b/www/usethesource/templates/usethesource/candidate.html
index 1dca667b..8d396d22 100644
--- a/www/usethesource/templates/usethesource/candidate.html
+++ b/www/usethesource/templates/usethesource/candidate.html
@@ -1,35 +1,36 @@
 {% extends "usethesource/base.html" %}
 
+{% block head %}
+  {{ block.super }}
+  <script src="https://unpkg.com/htmx.org@1.9.6"></script>
+{% endblock %}
+
 {% block content %}
   {{ block.super }}
 
   <section class="pa2 mt4 mb3">
     <div style="display: flex; justify-content: space-between">
       <div>
-        <h2 class="f2 lh-title ttu mt0">Linksys WRG987-v2</h2>
-        <p><strong>Vendor</strong>: Linksys</em></p>
-        <p><strong>Device</strong>: WRG987</em></p>
-        <p><strong>Released</strong>: Oct 20, 2023</em></p>
+        <h2 class="f2 lh-title ttu mt0">{{ candidate.name }}</h2>
+        <p><strong>Vendor</strong>: {{ candidate.vendor }}</em></p>
+        <p><strong>Device</strong>: {{ candidate.device }}</em></p>
+        <p><strong>Released</strong>: {{ candidate.release_date }}</em></p>
       </div>
       <div class="mt2">
-        <div><a href="{% url 'usethesource:download' %}" class="white bg-green db pv2 ph3 mb2">Download source</a></div>
-        <div><a href="{% url 'usethesource:download' %}" class="white bg-green db pv2 ph3">Download image</a></div>
+        <div><a href="{% url 'usethesource:download' slug=candidate.slug download_type='source' %}" class="white bg-green db pv2 ph3 mb2">Download source</a></div>
+        <div><a href="{% url 'usethesource:download' slug=candidate.slug download_type='binary' %}" class="white bg-green db pv2 ph3">Download image</a></div>
       </div>
     </div>
 
-    <p>The WRG1830 Wireless Router Gateway (WRG) is a component of the ZFR183x Pro Series Wireless Field Bus System. The WRG1830 provides BACnet IP connectivity to compatible Johnson Controls Field Controllers, VAV Controllers, Thermostats, and Sensors over a wireless mesh network.</p>
+    <p>{{ candidate.description }}</p>
 
     <h3 class="f3 lh-title mt4">Comments</h3>
-    <p><strong>Denver — Oct 20, 2023</strong>:<br>
-      I was able to run the autogen and configure scripts, but the build failed with a missing dependency XYZ.</p>
+    {% for comment in candidate.comment_set.all %}
+      {% include "usethesource/comment_partial.html" %}
+    {% endfor %}
 
-    <form method="post">
-      <div class="mt4">
-        <textarea placeholder="Add a comment..." style="width: 50em; height: 15em;"></textarea>
-      </div>
-      <div class="mt2">
-        <button type="submit" class="b white bg-green pv2 ph3" style="border: none">Save</button>
-      </div>
-    </form>
+    {% if user.is_staff %}
+      {% include "usethesource/add_comment_button_partial.html" %}
+    {% endif %}
   </section>
 {% endblock content %}
diff --git a/www/usethesource/templates/usethesource/comment_deleted.html b/www/usethesource/templates/usethesource/comment_deleted.html
new file mode 100644
index 00000000..f6ed46bc
--- /dev/null
+++ b/www/usethesource/templates/usethesource/comment_deleted.html
@@ -0,0 +1,3 @@
+{% if add and user.is_staff %}
+  {% include 'usethesource/add_comment_button_partial.html' %}
+{% endif %}
diff --git a/www/usethesource/templates/usethesource/comment_form.html b/www/usethesource/templates/usethesource/comment_form.html
new file mode 100644
index 00000000..cfda8b4d
--- /dev/null
+++ b/www/usethesource/templates/usethesource/comment_form.html
@@ -0,0 +1,8 @@
+<form hx-target="this" hx-swap="outerHTML" hx-post="{% url 'usethesource:add_comment' slug=candidate.slug %}" method="post">
+  {% csrf_token %}
+  {{ form.message }}
+  <div class="mt2">
+    <button type="submit" hx-get="{% url 'usethesource:add_button' slug=candidate.slug %}" class="b pointer white bg-light-silver pv2 ph3" style="border: none">Cancel</button>
+    {% include 'usethesource/save_button_partial.html' %}
+  </div>
+</form>
diff --git a/www/usethesource/templates/usethesource/comment_partial.html b/www/usethesource/templates/usethesource/comment_partial.html
new file mode 100644
index 00000000..ccbfc0c6
--- /dev/null
+++ b/www/usethesource/templates/usethesource/comment_partial.html
@@ -0,0 +1,7 @@
+<div hx-target="this" hx-swap="outerHTML"><strong>{{ comment.user }} — {{ comment.time }}</strong>
+  {% if user.is_staff %}
+    <a href="#" class="f7 white bg-light-silver ph2" hx-get="{% url 'usethesource:edit_comment' comment_id=comment.id %}">edit</a>
+    <a href="#" class="f7 white bg-light-red ph2" hx-delete="{% url 'usethesource:delete_comment' comment_id=comment.id show_add='false' %}" hx-confirm="Are you sure you want to delete this comment?">delete</a>
+  {% endif %}
+  <br>{{ comment.message|linebreaks }}
+</div>
diff --git a/www/usethesource/templates/usethesource/download.html b/www/usethesource/templates/usethesource/download.html
index 791e5421..792441c5 100644
--- a/www/usethesource/templates/usethesource/download.html
+++ b/www/usethesource/templates/usethesource/download.html
@@ -5,13 +5,13 @@
 
   <section class="pa2 mt4 mb5">
     <h2 class="f2 lh-title ttu mt0">Download</h2>
-    <p><strong>File</strong>: xyz-image.tar.gz</p>
+    <p><strong>File</strong>: {{ download_url }}</p>
 
     <p>By downloading this, you agree to use it only for the purposes of GPL compliance checking, unless otherwise permitted by the license.</p>
 
     <div style="display: flex">
-      <a href="{% url 'usethesource:candidate' %}" class="white bg-silver dib pv2 ph3 mb2 mr2">Back</a>
-      <a href="{% url 'usethesource:download' %}" class="white bg-green dib pv2 ph3 mb2">Download</a>
+      <a href="{% url 'usethesource:candidate' slug=candidate.slug %}" class="white bg-silver dib pv2 ph3 mb2 mr2">Back</a>
+      <a href="{{ download_url }}" class="white bg-green dib pv2 ph3 mb2">Download</a>
     </div>
   </section>
 {% endblock content %}
diff --git a/www/usethesource/templates/usethesource/edit_comment_form.html b/www/usethesource/templates/usethesource/edit_comment_form.html
new file mode 100644
index 00000000..e03998bc
--- /dev/null
+++ b/www/usethesource/templates/usethesource/edit_comment_form.html
@@ -0,0 +1,8 @@
+<form class="mb3" hx-target="this" hx-swap="outerHTML" hx-post="{% url 'usethesource:edit_comment' comment_id=comment.id %}" method="post">
+  {% csrf_token %}
+  {{ form.message }}
+  <div class="mt2">
+    <button type="submit" hx-get="{% url 'usethesource:view_comment' comment_id=comment.id show_add='false' %}" class="b pointer white bg-light-silver pv2 ph3" style="border: none">Cancel</button>
+    {% include 'usethesource/save_button_partial.html' %}
+  </div>
+</form>
diff --git a/www/usethesource/templates/usethesource/landing_page.html b/www/usethesource/templates/usethesource/landing_page.html
index 5dcc0c2e..2432e6ff 100644
--- a/www/usethesource/templates/usethesource/landing_page.html
+++ b/www/usethesource/templates/usethesource/landing_page.html
@@ -9,16 +9,12 @@
 
   <section class="mb5">
     <h2 class="f2 lh-title ttu mt0">Candidates</h2>
-    <div class="mb4">
-      <h3 class="f4 lh-title mt0"><a href="{% url 'usethesource:candidate' %}">Linksys WRG987-v2</a></h3>
-      <p class=""><em>Released Oct 20, 2023</em></p>
-      <p class="">The WRG1830 Wireless Router Gateway (WRG) is a component of the ZFR183x Pro Series Wireless Field Bus System. The WRG1830 provides BACnet IP connectivity to compatible Johnson Controls Field Controllers, VAV Controllers, Thermostats, and Sensors over a wireless mesh network.</p>
-    </div>
-
-    <div>
-      <h2 class="lh-title mt0"><a href="{% url 'usethesource:candidate' %}">Linksys WRG987-v2</a></h2>
-      <p><em>Released Oct 20, 2023</em></p>
-      <p>The WRG1830 Wireless Router Gateway (WRG) is a component of the ZFR183x Pro Series Wireless Field Bus System. The WRG1830 provides BACnet IP connectivity to compatible Johnson Controls Field Controllers, VAV Controllers, Thermostats, and Sensors over a wireless mesh network.</p>
-    </div>
+    {% for candidate in candidates %}
+      <div class="mb4">
+        <h3 class="f4 lh-title mt0"><a href="{% url 'usethesource:candidate' slug=candidate.slug %}">{{ candidate.name }}</a></h3>
+        <p><em>Released {{ candidate.release_date }}</em></p>
+        <p>{{ candidate.description }}</p>
+      </div>
+    {% endfor %}
 </section>
 {% endblock content %}
diff --git a/www/usethesource/templates/usethesource/returned_comment.html b/www/usethesource/templates/usethesource/returned_comment.html
new file mode 100644
index 00000000..62e91ab4
--- /dev/null
+++ b/www/usethesource/templates/usethesource/returned_comment.html
@@ -0,0 +1,5 @@
+{% include 'usethesource/comment_partial.html' %}
+
+{% if add and user.is_staff %}
+  {% include 'usethesource/add_comment_button_partial.html' %}
+{% endif %}
diff --git a/www/usethesource/templates/usethesource/save_button_partial.html b/www/usethesource/templates/usethesource/save_button_partial.html
new file mode 100644
index 00000000..8ae5cdff
--- /dev/null
+++ b/www/usethesource/templates/usethesource/save_button_partial.html
@@ -0,0 +1 @@
+<button type="submit" class="b white bg-green pv2 ph3" style="border: none">Save</button>
diff --git a/www/usethesource/urls.py b/www/usethesource/urls.py
index e3f03bfa..0eb550c3 100644
--- a/www/usethesource/urls.py
+++ b/www/usethesource/urls.py
@@ -5,6 +5,11 @@ from . import views
 app_name = 'usethesource'
 urlpatterns = [
     path('', views.landing_page, name='landing'),
-    path('candidate/', views.candidate_page, name='candidate'),
-    path('download/', views.download_page, name='download'),
+    path('candidate/<slug:slug>/', views.candidate_page, name='candidate'),
+    path('download/<slug:slug>/<download_type>/', views.download_page, name='download'),
+    path('add-comment/<slug:slug>/', views.create_comment, name='add_comment'),
+    path('edit-comment/<int:comment_id>/', views.edit_comment, name='edit_comment'),
+    path('comment/<int:comment_id>/<show_add>/', views.view_comment, name='view_comment'),
+    path('delete-comment/<int:comment_id>/<show_add>/', views.delete_comment, name='delete_comment'),
+    path('add-button/<slug:slug>/', views.add_button, name='add_button'),
 ]
diff --git a/www/usethesource/views.py b/www/usethesource/views.py
index 64a0e1c6..e9740178 100644
--- a/www/usethesource/views.py
+++ b/www/usethesource/views.py
@@ -1,13 +1,81 @@
-from django.shortcuts import render
+from django import forms
+from django.contrib.admin.views.decorators import staff_member_required
+from django.shortcuts import get_object_or_404, redirect, render
+
+from .models import Candidate, Comment
 
 
 def landing_page(request):
-    return render(request, 'usethesource/landing_page.html', {})
+    candidates = Candidate.objects.all()
+    return render(request, 'usethesource/landing_page.html', {'candidates': candidates})
 
 
-def candidate_page(request):
-    return render(request, 'usethesource/candidate.html', {})
+def candidate_page(request, slug):
+    candidate = get_object_or_404(Candidate, slug=slug)
+    return render(request, 'usethesource/candidate.html', {'candidate': candidate, 'add': True})
 
 
-def download_page(request):
-    return render(request, 'usethesource/download.html', {})
+def download_page(request, slug, download_type):
+    candidate = get_object_or_404(Candidate, slug=slug)
+    url = candidate.source_url if download_type == 'source' else candidate.binary_url
+    return render(
+        request,
+        'usethesource/download.html',
+        {'candidate': candidate, 'download_url': url},
+    )
+
+
+class CommentForm(forms.ModelForm):
+    class Meta:
+        model = Comment
+        fields = ['message']
+
+
+@staff_member_required
+def create_comment(request, slug):
+    candidate = get_object_or_404(Candidate, slug=slug)
+    if request.method == 'GET':
+        form = CommentForm()
+    else:
+        form = CommentForm(request.POST)
+        if form.is_valid():
+            instance = form.save(commit=False)
+            instance.candidate = candidate
+            instance.user = request.user
+            instance.save()
+            return redirect('usethesource:view_comment', comment_id=instance.id, show_add='true')
+    return render(request, 'usethesource/comment_form.html', {'form': form, 'candidate': candidate})
+
+
+@staff_member_required
+def edit_comment(request, comment_id):
+    comment = get_object_or_404(Comment, id=comment_id)
+    if request.method == 'GET':
+        form = CommentForm(instance=comment)
+    else:
+        form = CommentForm(request.POST, instance=comment)
+        if form.is_valid():
+            instance = form.save(commit=False)
+            instance.save()
+            return redirect('usethesource:view_comment', comment_id=instance.id, show_add='false')
+    return render(request, 'usethesource/edit_comment_form.html', {'form': form, 'comment': comment})
+
+
+@staff_member_required
+def view_comment(request, comment_id, show_add):
+    show_add = show_add == 'true'
+    comment = get_object_or_404(Comment, id=comment_id)
+    return render(request, 'usethesource/returned_comment.html', {'comment': comment, 'candidate': comment.candidate, 'add': show_add})
+
+
+@staff_member_required
+def delete_comment(request, comment_id, show_add):
+    show_add = show_add == 'true'
+    Comment.objects.filter(id=comment_id).delete()
+    return render(request, 'usethesource/comment_deleted.html', {'comment': None, 'add': show_add})
+
+
+@staff_member_required
+def add_button(request, slug):
+    candidate = get_object_or_404(Candidate, slug=slug)
+    return render(request, 'usethesource/add_comment_button_partial.html', {'candidate': candidate})