diff --git a/opentech/projects/migrations/0001_initial.py b/opentech/projects/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..91221490357a3d5d0f5e7cfa753d447cfe6284ad
--- /dev/null
+++ b/opentech/projects/migrations/0001_initial.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.8 on 2018-01-05 16:18
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import modelcluster.fields
+import wagtail.wagtailcore.blocks
+import wagtail.wagtailcore.fields
+import wagtail.wagtaildocs.blocks
+import wagtail.wagtailembeds.blocks
+import wagtail.wagtailimages.blocks
+import wagtail.wagtailsnippets.blocks
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('wagtailcore', '0040_page_draft_title'),
+        ('images', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ProjectIndexPage',
+            fields=[
+                ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')),
+                ('social_text', models.CharField(blank=True, max_length=255)),
+                ('listing_title', models.CharField(blank=True, help_text='Override the page title used when this page appears in listings', max_length=255)),
+                ('listing_summary', models.CharField(blank=True, help_text="The text summary used when this page appears in listings. It's also used as the description for search engines if the 'Search description' field above is not defined.", max_length=255)),
+                ('introduction', models.TextField(blank=True)),
+                ('listing_image', models.ForeignKey(blank=True, help_text='Choose the image you wish to be displayed when this page appears in listings', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.CustomImage')),
+                ('social_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.CustomImage')),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=('wagtailcore.page', models.Model),
+        ),
+        migrations.CreateModel(
+            name='ProjectPage',
+            fields=[
+                ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')),
+                ('social_text', models.CharField(blank=True, max_length=255)),
+                ('listing_title', models.CharField(blank=True, help_text='Override the page title used when this page appears in listings', max_length=255)),
+                ('listing_summary', models.CharField(blank=True, help_text="The text summary used when this page appears in listings. It's also used as the description for search engines if the 'Search description' field above is not defined.", max_length=255)),
+                ('introduction', models.TextField(blank=True)),
+                ('body', wagtail.wagtailcore.fields.StreamField((('heading', wagtail.wagtailcore.blocks.CharBlock(classname='full title', icon='title')), ('paragraph', wagtail.wagtailcore.blocks.RichTextBlock()), ('image', wagtail.wagtailcore.blocks.StructBlock((('image', wagtail.wagtailimages.blocks.ImageChooserBlock()), ('caption', wagtail.wagtailcore.blocks.CharBlock(required=False))))), ('quote', wagtail.wagtailcore.blocks.StructBlock((('quote', wagtail.wagtailcore.blocks.CharBlock(classname='title')), ('attribution', wagtail.wagtailcore.blocks.CharBlock(required=False))))), ('embed', wagtail.wagtailembeds.blocks.EmbedBlock()), ('call_to_action', wagtail.wagtailsnippets.blocks.SnippetChooserBlock('utils.CallToActionSnippet', template='blocks/call_to_action_block.html')), ('document', wagtail.wagtailcore.blocks.StructBlock((('document', wagtail.wagtaildocs.blocks.DocumentChooserBlock()), ('title', wagtail.wagtailcore.blocks.CharBlock(required=False)))))))),
+                ('listing_image', models.ForeignKey(blank=True, help_text='Choose the image you wish to be displayed when this page appears in listings', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.CustomImage')),
+                ('social_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.CustomImage')),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=('wagtailcore.page', models.Model),
+        ),
+        migrations.CreateModel(
+            name='ProjectPageRelatedPage',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
+                ('page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.Page')),
+                ('source_page', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_pages', to='projects.ProjectPage')),
+            ],
+            options={
+                'ordering': ['sort_order'],
+                'abstract': False,
+            },
+        ),
+    ]
diff --git a/opentech/projects/models.py b/opentech/projects/models.py
index 71a836239075aa6e6e4ecb700e9c42c95c022d91..037e691682145d92c08e2abd6357aa37e3747b20 100644
--- a/opentech/projects/models.py
+++ b/opentech/projects/models.py
@@ -1,3 +1,85 @@
 from django.db import models
+from django.conf import settings
+from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
 
-# Create your models here.
+from modelcluster.fields import ParentalKey
+from wagtail.wagtailadmin.edit_handlers import (
+    FieldPanel,
+    InlinePanel,
+    PageChooserPanel,
+    StreamFieldPanel,
+)
+
+from wagtail.wagtailcore.fields import StreamField
+from wagtail.wagtailsearch import index
+
+from opentech.utils.blocks import StoryBlock
+from opentech.utils.models import (
+    BasePage,
+    RelatedPage,
+)
+
+
+class ProjectPageRelatedPage(RelatedPage):
+    source_page = ParentalKey('ProjectPage', related_name='related_pages')
+
+    panels = [
+        PageChooserPanel('page', 'projects.ProjectPage'),
+    ]
+
+
+class ProjectPage(BasePage):
+    subpage_types = []
+    parent_page_types = ['ProjectIndexPage']
+
+    introduction = models.TextField(blank=True)
+    body = StreamField(StoryBlock())
+
+    # Fields to add:
+    # otf_status
+    # status
+    # social_accounts
+    # website
+    # funding
+
+    search_fields = BasePage.search_fields + [
+        index.SearchField('introduction'),
+        index.SearchField('body'),
+    ]
+
+    content_panels = BasePage.content_panels + [
+        FieldPanel('introduction'),
+        StreamFieldPanel('body'),
+        InlinePanel('related_pages', label="Related pages"),
+    ]
+
+
+class ProjectIndexPage(BasePage):
+
+    introduction = models.TextField(blank=True)
+
+    content_panels = BasePage.content_panels + [
+        FieldPanel('introduction'),
+    ]
+
+    search_fields = BasePage.search_fields + [
+        index.SearchField('introduction'),
+    ]
+
+    def get_context(self, request, *args, **kwargs):
+        context = super().get_context(request, *args, **kwargs)
+        subpages = self.get_children().live()
+        per_page = settings.DEFAULT_PER_PAGE
+        page_number = request.GET.get('page')
+        paginator = Paginator(subpages, per_page)
+
+        try:
+            subpages = paginator.page(page_number)
+        except PageNotAnInteger:
+            subpages = paginator.page(1)
+        except EmptyPage:
+            subpages = paginator.page(paginator.num_pages)
+
+        context['subpages'] = subpages
+
+        return context
diff --git a/opentech/settings/base.py b/opentech/settings/base.py
index ced6888bc9e76d811695568ff455bf531c856afa..20e1f931d20ed890fd44616706fbac86ed0016ce 100644
--- a/opentech/settings/base.py
+++ b/opentech/settings/base.py
@@ -27,6 +27,7 @@ INSTALLED_APPS = [
     'opentech.public.navigation',
     'opentech.public.news',
     'opentech.public.people',
+    'opentech.projects',
     'opentech.public.search',
     'opentech.public.standardpages',
     'opentech.public.utils',