diff --git a/app/contributions/__init__.py b/app/contributions/__init__.py
index af9747a67b39250b7dd0a857948fe915462af504..5175c0ce7d5c6b907a1cd0fe1a6f3df140f580b4 100644
--- a/app/contributions/__init__.py
+++ b/app/contributions/__init__.py
@@ -3,3 +3,15 @@ from flask import Blueprint
 
 bp = Blueprint('contributions', __name__)
 from . import routes
+
+from .spacy_nlp_pipeline_models import bp as spacy_nlp_pipeline_models_bp
+bp.register_blueprint(
+    spacy_nlp_pipeline_models_bp,
+    url_prefix='/spacy-nlp-pipeline-models'
+)
+
+from .tesseract_ocr_pipeline_models import bp as tesseract_ocr_pipeline_models_bp
+bp.register_blueprint(
+    tesseract_ocr_pipeline_models_bp,
+    url_prefix='/tesseract-ocr-pipeline-models'
+)
diff --git a/app/contributions/forms.py b/app/contributions/forms.py
index eb25babbe74d6161c71d1154f5eca9c311199782..acec307f00b4e409ff962cc7e0021973a442f2fe 100644
--- a/app/contributions/forms.py
+++ b/app/contributions/forms.py
@@ -1,16 +1,11 @@
-from flask import current_app
 from flask_wtf import FlaskForm
-from flask_wtf.file import FileField, FileRequired
 from wtforms import (
-    BooleanField,
     StringField,
     SubmitField,
     SelectMultipleField,
-    IntegerField,
-    ValidationError
+    IntegerField
 )
 from wtforms.validators import InputRequired, Length
-from app.services import SERVICES
 
 
 class ContributionBaseForm(FlaskForm):
@@ -48,74 +43,5 @@ class ContributionBaseForm(FlaskForm):
     submit = SubmitField()
 
 
-class CreateTesseractOCRPipelineModelForm(ContributionBaseForm):
-    tesseract_model_file = FileField(
-        'File',
-        validators=[FileRequired()]
-    )
-    
-    def validate_tesseract_model_file(self, field):
-        if not field.data.filename.lower().endswith('.traineddata'):
-            raise ValidationError('traineddata files only!')
-
-    def __init__(self, *args, **kwargs):
-        service_manifest = SERVICES['tesseract-ocr-pipeline']
-        super().__init__(*args, **kwargs)
-        self.compatible_service_versions.choices = [('', 'Choose your option')]
-        self.compatible_service_versions.choices += [
-            (x, x) for x in service_manifest['versions'].keys()
-        ]
-        self.compatible_service_versions.default = ''
-
-
-class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm):
-    spacy_model_file = FileField(
-        'File',
-        validators=[FileRequired()]
-    )
-    pipeline_name = StringField(
-        'Pipeline name',
-        validators=[InputRequired(), Length(max=64)]
-    )
-
-    def validate_spacy_model_file(self, field):
-        if not field.data.filename.lower().endswith('.tar.gz'):
-            raise ValidationError('.tar.gz files only!')
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        service_manifest = SERVICES['spacy-nlp-pipeline']
-        self.compatible_service_versions.choices = [('', 'Choose your option')]
-        self.compatible_service_versions.choices += [
-            (x, x) for x in service_manifest['versions'].keys()
-        ]
-        self.compatible_service_versions.default = ''
-
-
 class EditContributionBaseForm(ContributionBaseForm):
     pass
-
-class EditTesseractOCRPipelineModelForm(EditContributionBaseForm):
-    def __init__(self, *args, **kwargs):
-        service_manifest = SERVICES['tesseract-ocr-pipeline']
-        super().__init__(*args, **kwargs)
-        self.compatible_service_versions.choices = [('', 'Choose your option')]
-        self.compatible_service_versions.choices += [
-            (x, x) for x in service_manifest['versions'].keys()
-        ]
-        self.compatible_service_versions.default = ''
-
-
-class EditSpaCyNLPPipelineModelForm(EditContributionBaseForm):
-    pipeline_name = StringField(
-        'Pipeline name',
-        validators=[InputRequired(), Length(max=64)]
-    )
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        service_manifest = SERVICES['spacy-nlp-pipeline']
-        self.compatible_service_versions.choices = [('', 'Choose your option')]
-        self.compatible_service_versions.choices += [
-            (x, x) for x in service_manifest['versions'].keys()
-        ]
-        self.compatible_service_versions.default = ''
diff --git a/app/contributions/routes.py b/app/contributions/routes.py
index 3bc37eb885802bbd076485db3764a0fc9749d720..6d8b9cc3ab62b318b9c9a61fe4442e2698c9ec87 100644
--- a/app/contributions/routes.py
+++ b/app/contributions/routes.py
@@ -1,233 +1,12 @@
-from flask import (
-    abort,
-    current_app,
-    flash,
-    Markup,
-    redirect,
-    render_template,
-    url_for
-)
-from flask_login import login_required, current_user
-from threading import Thread
-from app import db
-from app.decorators import permission_required 
-from app.models import (
-    Permission,
-    SpaCyNLPPipelineModel,
-    TesseractOCRPipelineModel
-)
+from flask import render_template
+from flask_login import login_required
 from . import bp
-from .forms import (
-    CreateSpaCyNLPPipelineModelForm,
-    CreateTesseractOCRPipelineModelForm,
-    EditSpaCyNLPPipelineModelForm,
-    EditTesseractOCRPipelineModelForm
-)
-
-
-@bp.before_request
-@login_required
-def before_request():
-    pass
 
 
 @bp.route('/')
+@login_required
 def contributions():
     return render_template(
         'contributions/contributions.html.j2',
         title='Contributions'
     )
-
-
-@bp.route('/tesseract-ocr-pipeline-models')
-def tesseract_ocr_pipeline_models():
-    return render_template(
-        'contributions/tesseract_ocr_pipeline_models.html.j2',
-        title='Tesseract OCR Pipeline Models'
-    )
-
-
-@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
-def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
-    tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
-    form = EditTesseractOCRPipelineModelForm(
-        data=tesseract_ocr_pipeline_model.to_json_serializeable(),
-        prefix='edit-tesseract-ocr-pipeline-model-form'
-    )
-    if form.validate_on_submit():
-        form.populate_obj(tesseract_ocr_pipeline_model)
-        if db.session.is_modified(tesseract_ocr_pipeline_model):
-            message = Markup(f'Tesseract OCR Pipeline model "<a href="{tesseract_ocr_pipeline_model.url}">{tesseract_ocr_pipeline_model.title}</a>" updated')
-            flash(message)
-            db.session.commit()
-        return redirect(url_for('.tesseract_ocr_pipeline_models'))
-    return render_template(
-        'contributions/tesseract_ocr_pipeline_model.html.j2',
-        form=form,
-        tesseract_ocr_pipeline_model=tesseract_ocr_pipeline_model,
-        title=f'{tesseract_ocr_pipeline_model.title} {tesseract_ocr_pipeline_model.version}'
-    )
-
-
-@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
-def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
-    def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id):
-        with app.app_context():
-            tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get(tesseract_ocr_pipeline_model_id)
-            tesseract_ocr_pipeline_model.delete()
-            db.session.commit()
-
-    tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
-    if not (tesseract_ocr_pipeline_model.user == current_user or current_user.is_administrator()):
-        abort(403)
-    thread = Thread(
-        target=_delete_tesseract_ocr_pipeline_model,
-        args=(current_app._get_current_object(), tesseract_ocr_pipeline_model_id)
-    )
-    thread.start()
-    return {}, 202
-
-
-@bp.route('/tesseract-ocr-pipeline-models/create', methods=['GET', 'POST'])
-def create_tesseract_ocr_pipeline_model():
-    form = CreateTesseractOCRPipelineModelForm(prefix='create-tesseract-ocr-pipeline-model-form')
-    if form.is_submitted():
-        if not form.validate():
-            response = {'errors': form.errors}
-            return response, 400
-        try:
-            tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.create(
-                form.tesseract_model_file.data,
-                compatible_service_versions=form.compatible_service_versions.data,
-                description=form.description.data,
-                publisher=form.publisher.data,
-                publisher_url=form.publisher_url.data,
-                publishing_url=form.publishing_url.data,
-                publishing_year=form.publishing_year.data,
-                is_public=False,
-                title=form.title.data,
-                version=form.version.data,
-                user=current_user
-            )
-        except OSError:
-            abort(500)
-        db.session.commit()
-        tesseract_ocr_pipeline_model_url = url_for(
-            '.tesseract_ocr_pipeline_model',
-            tesseract_ocr_pipeline_model_id=tesseract_ocr_pipeline_model.id
-        )
-        message = Markup(f'Tesseract OCR Pipeline model "<a href="{tesseract_ocr_pipeline_model_url}">{tesseract_ocr_pipeline_model.title}</a>" created')
-        flash(message)
-        return {}, 201, {'Location': tesseract_ocr_pipeline_model_url}
-    return render_template(
-        'contributions/create_tesseract_ocr_pipeline_model.html.j2',
-        form=form,
-        title='Create Tesseract OCR Pipeline Model'
-    )
-
-@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>/toggle-public-status', methods=['POST'])
-@permission_required(Permission.CONTRIBUTE)
-def toggle_tesseract_ocr_pipeline_model_public_status(tesseract_ocr_pipeline_model_id):
-    tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
-    if not (tesseract_ocr_pipeline_model.user == current_user or current_user.is_administrator()):
-        abort(403)
-    tesseract_ocr_pipeline_model.is_public = not tesseract_ocr_pipeline_model.is_public
-    db.session.commit()
-    return {}, 201
-
-
-@bp.route('/spacy-nlp-pipeline-models')
-def spacy_nlp_pipeline_models():
-    return render_template(
-        'contributions/spacy_nlp_pipeline_models.html.j2',
-        title='SpaCy NLP Pipeline Models'
-    )
-
-
-@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
-def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
-    spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
-    form = EditSpaCyNLPPipelineModelForm(
-        data=spacy_nlp_pipeline_model.to_json_serializeable(),
-        prefix='edit-spacy-nlp-pipeline-model-form'
-    )
-    if form.validate_on_submit():
-        form.populate_obj(spacy_nlp_pipeline_model)
-        if db.session.is_modified(spacy_nlp_pipeline_model):
-            message = Markup(f'SpaCy NLP Pipeline model "<a href="{spacy_nlp_pipeline_model.url}">{spacy_nlp_pipeline_model.title}</a>" updated')
-            flash(message)
-            db.session.commit()
-        return redirect(url_for('.spacy_nlp_pipeline_models'))
-    return render_template(
-        'contributions/spacy_nlp_pipeline_model.html.j2',
-        form=form,
-        spacy_nlp_pipeline_model=spacy_nlp_pipeline_model,
-        title=f'{spacy_nlp_pipeline_model.title} {spacy_nlp_pipeline_model.version}'
-    )
-
-@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
-def delete_spacy_model(spacy_nlp_pipeline_model_id):
-    def _delete_spacy_model(app, spacy_nlp_pipeline_model_id):
-        with app.app_context():
-            spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
-            spacy_nlp_pipeline_model.delete()
-            db.session.commit()
-    
-    spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
-    if not (spacy_nlp_pipeline_model.user == current_user or current_user.is_administrator()):
-        abort(403)
-    thread = Thread(
-        target=_delete_spacy_model,
-        args=(current_app._get_current_object(), spacy_nlp_pipeline_model_id)
-    )
-    thread.start()
-    return {}, 202
-
-
-@bp.route('/spacy-nlp-pipeline-models/create', methods=['GET', 'POST'])
-def create_spacy_nlp_pipeline_model():
-    form = CreateSpaCyNLPPipelineModelForm(prefix='create-spacy-nlp-pipeline-model-form')
-    if form.is_submitted():
-        if not form.validate():
-            response = {'errors': form.errors}
-            return response, 400
-        try:
-            spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.create(
-                form.spacy_model_file.data,
-                compatible_service_versions=form.compatible_service_versions.data,
-                description=form.description.data,
-                pipeline_name=form.pipeline_name.data,
-                publisher=form.publisher.data,
-                publisher_url=form.publisher_url.data,
-                publishing_url=form.publishing_url.data,
-                publishing_year=form.publishing_year.data,
-                is_public=False,
-                title=form.title.data,
-                version=form.version.data,
-                user=current_user
-            )
-        except OSError:
-            abort(500)
-        db.session.commit()
-        spacy_nlp_pipeline_model_url = url_for(
-            '.spacy_nlp_pipeline_model',
-            spacy_nlp_pipeline_model_id=spacy_nlp_pipeline_model.id
-        )
-        message = Markup(f'SpaCy NLP Pipeline model "<a href="{spacy_nlp_pipeline_model_url}">{spacy_nlp_pipeline_model.title}</a>" created')
-        flash(message)
-        return {}, 201, {'Location': spacy_nlp_pipeline_model_url}
-    return render_template(
-        'contributions/create_spacy_nlp_pipeline_model.html.j2',
-        form=form,
-        title='Create SpaCy NLP Pipeline Model'
-    )
-
-@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>/toggle-public-status', methods=['POST'])
-@permission_required(Permission.CONTRIBUTE)
-def toggle_spacy_nlp_pipeline_model_public_status(spacy_nlp_pipeline_model_id):
-    spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
-    if not (spacy_nlp_pipeline_model.user == current_user or current_user.is_administrator()):
-        abort(403)
-    spacy_nlp_pipeline_model.is_public = not spacy_nlp_pipeline_model.is_public
-    db.session.commit()
-    return {}, 201
diff --git a/app/contributions/spacy_nlp_pipeline_models/__init__.py b/app/contributions/spacy_nlp_pipeline_models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..6b73681a167c86724af39c199069732b0c7e56ac
--- /dev/null
+++ b/app/contributions/spacy_nlp_pipeline_models/__init__.py
@@ -0,0 +1,8 @@
+from flask import Blueprint
+
+
+TEMPLATE_FOLDER = 'contributions/spacy_nlp_pipeline_models'
+
+
+bp = Blueprint('spacy_nlp_pipeline_models', __name__)
+from . import routes, json_routes
diff --git a/app/contributions/spacy_nlp_pipeline_models/forms.py b/app/contributions/spacy_nlp_pipeline_models/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..2670c1d1c0ba126c1eabf911b09475eda53c3799
--- /dev/null
+++ b/app/contributions/spacy_nlp_pipeline_models/forms.py
@@ -0,0 +1,44 @@
+from flask_wtf.file import FileField, FileRequired
+from wtforms import StringField, ValidationError
+from wtforms.validators import InputRequired, Length
+from app.services import SERVICES
+from ..forms import ContributionBaseForm, EditContributionBaseForm
+
+
+class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm):
+    spacy_model_file = FileField(
+        'File',
+        validators=[FileRequired()]
+    )
+    pipeline_name = StringField(
+        'Pipeline name',
+        validators=[InputRequired(), Length(max=64)]
+    )
+
+    def validate_spacy_model_file(self, field):
+        if not field.data.filename.lower().endswith('.tar.gz'):
+            raise ValidationError('.tar.gz files only!')
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        service_manifest = SERVICES['spacy-nlp-pipeline']
+        self.compatible_service_versions.choices = [('', 'Choose your option')]
+        self.compatible_service_versions.choices += [
+            (x, x) for x in service_manifest['versions'].keys()
+        ]
+        self.compatible_service_versions.default = ''
+
+
+class EditSpaCyNLPPipelineModelForm(EditContributionBaseForm):
+    pipeline_name = StringField(
+        'Pipeline name',
+        validators=[InputRequired(), Length(max=64)]
+    )
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        service_manifest = SERVICES['spacy-nlp-pipeline']
+        self.compatible_service_versions.choices = [('', 'Choose your option')]
+        self.compatible_service_versions.choices += [
+            (x, x) for x in service_manifest['versions'].keys()
+        ]
+        self.compatible_service_versions.default = ''
diff --git a/app/contributions/spacy_nlp_pipeline_models/json_routes.py b/app/contributions/spacy_nlp_pipeline_models/json_routes.py
new file mode 100644
index 0000000000000000000000000000000000000000..9247f85b27f0f8b543933b726edafea074c36f6d
--- /dev/null
+++ b/app/contributions/spacy_nlp_pipeline_models/json_routes.py
@@ -0,0 +1,58 @@
+from flask import abort, current_app, jsonify, request
+from flask_login import login_required, current_user
+from threading import Thread
+from app import db
+from app.decorators import content_negotiation, permission_required
+from app.models import SpaCyNLPPipelineModel
+from . import bp
+
+
+@bp.route('/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
+@login_required
+@content_negotiation(produces='application/json')
+def delete_spacy_model(spacy_nlp_pipeline_model_id):
+    def _delete_spacy_model(app, spacy_nlp_pipeline_model_id):
+        with app.app_context():
+            snpm = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
+            snpm.delete()
+            db.session.commit()
+    
+    snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
+    if not (snpm.user == current_user or current_user.is_administrator()):
+        abort(403)
+    thread = Thread(
+        target=_delete_spacy_model,
+        args=(current_app._get_current_object(), snpm.id)
+    )
+    thread.start()
+    resonse_data = {
+        'message': \
+            f'SpaCy NLP Pipeline Model "{snpm.title}" marked for deletion'
+    }
+    response = jsonify(resonse_data)
+    response.status_code = 202
+    return response
+
+
+@bp.route('/<hashid:spacy_nlp_pipeline_model_id>/is_public', methods=['PUT'])
+@login_required
+@permission_required('CONTRIBUTE')
+@content_negotiation(consumes='application/json', produces='application/json')
+def update_spacy_nlp_pipeline_model_is_public(spacy_nlp_pipeline_model_id):
+    is_public = request.json
+    if not isinstance(is_public, bool):
+        abort(400)
+    snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
+    if not (snpm.user == current_user or current_user.is_administrator()):
+        abort(403)
+    snpm.is_public = is_public
+    db.session.commit()
+    response_data = {
+        'message': (
+            f'SpaCy NLP Pipeline Model "{snpm.title}"'
+            f' is now {"public" if is_public else "private"}'
+        )
+    }
+    response = jsonify(response_data)
+    response.status_code = 200
+    return response
diff --git a/app/contributions/spacy_nlp_pipeline_models/routes.py b/app/contributions/spacy_nlp_pipeline_models/routes.py
new file mode 100644
index 0000000000000000000000000000000000000000..2e416c478c70deb42254ab8fcac42ac5edd0a941
--- /dev/null
+++ b/app/contributions/spacy_nlp_pipeline_models/routes.py
@@ -0,0 +1,87 @@
+from flask import abort, flash, Markup, redirect, render_template, url_for
+from flask_login import login_required, current_user
+from app import db
+from app.models import SpaCyNLPPipelineModel
+from . import bp, TEMPLATE_FOLDER
+from .forms import (
+    CreateSpaCyNLPPipelineModelForm,
+    EditSpaCyNLPPipelineModelForm
+)
+
+
+@bp.route('')
+@login_required
+def spacy_nlp_pipeline_models():
+    return render_template(
+        f'{TEMPLATE_FOLDER}/spacy_nlp_pipeline_models.html.j2',
+        title='SpaCy NLP Pipeline Models'
+    )
+
+
+@bp.route('/create', methods=['GET', 'POST'])
+@login_required
+def create_spacy_nlp_pipeline_model():
+    form = CreateSpaCyNLPPipelineModelForm(prefix='create-spacy-nlp-pipeline-model-form')
+    if form.is_submitted():
+        if not form.validate():
+            response = {'errors': form.errors}
+            return response, 400
+        try:
+            spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.create(
+                form.spacy_model_file.data,
+                compatible_service_versions=form.compatible_service_versions.data,
+                description=form.description.data,
+                pipeline_name=form.pipeline_name.data,
+                publisher=form.publisher.data,
+                publisher_url=form.publisher_url.data,
+                publishing_url=form.publishing_url.data,
+                publishing_year=form.publishing_year.data,
+                is_public=False,
+                title=form.title.data,
+                version=form.version.data,
+                user=current_user
+            )
+        except OSError:
+            abort(500)
+        db.session.commit()
+        spacy_nlp_pipeline_model_url = url_for(
+            '.spacy_nlp_pipeline_model',
+            spacy_nlp_pipeline_model_id=spacy_nlp_pipeline_model.id
+        )
+        message = Markup(
+            f'SpaCy NLP Pipeline model "{spacy_nlp_pipeline_model.title}" '
+            'created'
+        )
+        flash(message)
+        return '', 201, {'Location': spacy_nlp_pipeline_model_url}
+    return render_template(
+        f'{TEMPLATE_FOLDER}/create_spacy_nlp_pipeline_model.html.j2',
+        form=form,
+        title='Create SpaCy NLP Pipeline Model'
+    )
+
+
+@bp.route('/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
+@login_required
+def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
+    spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
+    form = EditSpaCyNLPPipelineModelForm(
+        data=spacy_nlp_pipeline_model.to_json_serializeable(),
+        prefix='edit-spacy-nlp-pipeline-model-form'
+    )
+    if form.validate_on_submit():
+        form.populate_obj(spacy_nlp_pipeline_model)
+        if db.session.is_modified(spacy_nlp_pipeline_model):
+            message = Markup(
+                f'SpaCy NLP Pipeline model "{spacy_nlp_pipeline_model.title}" '
+                'updated'
+            )
+            flash(message)
+            db.session.commit()
+        return redirect(url_for('.spacy_nlp_pipeline_models'))
+    return render_template(
+        f'{TEMPLATE_FOLDER}/spacy_nlp_pipeline_model.html.j2',
+        form=form,
+        spacy_nlp_pipeline_model=spacy_nlp_pipeline_model,
+        title=f'{spacy_nlp_pipeline_model.title} {spacy_nlp_pipeline_model.version}'
+    )
diff --git a/app/contributions/tesseract_ocr_pipeline_models/__init__.py b/app/contributions/tesseract_ocr_pipeline_models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c60e0915d6c8029742183ea37f155799ce37bce0
--- /dev/null
+++ b/app/contributions/tesseract_ocr_pipeline_models/__init__.py
@@ -0,0 +1,8 @@
+from flask import Blueprint
+
+
+TEMPLATE_FOLDER = 'contributions/tesseract_ocr_pipeline_models'
+
+
+bp = Blueprint('tesseract_ocr_pipeline_models', __name__)
+from . import routes, json_routes
diff --git a/app/contributions/tesseract_ocr_pipeline_models/forms.py b/app/contributions/tesseract_ocr_pipeline_models/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..51f0d76c2017029c46691719d3131ee779d5c39c
--- /dev/null
+++ b/app/contributions/tesseract_ocr_pipeline_models/forms.py
@@ -0,0 +1,35 @@
+from flask_wtf.file import FileField, FileRequired
+from wtforms import ValidationError
+from app.services import SERVICES
+from ..forms import ContributionBaseForm, EditContributionBaseForm
+
+
+class CreateTesseractOCRPipelineModelForm(ContributionBaseForm):
+    tesseract_model_file = FileField(
+        'File',
+        validators=[FileRequired()]
+    )
+    
+    def validate_tesseract_model_file(self, field):
+        if not field.data.filename.lower().endswith('.traineddata'):
+            raise ValidationError('traineddata files only!')
+
+    def __init__(self, *args, **kwargs):
+        service_manifest = SERVICES['tesseract-ocr-pipeline']
+        super().__init__(*args, **kwargs)
+        self.compatible_service_versions.choices = [('', 'Choose your option')]
+        self.compatible_service_versions.choices += [
+            (x, x) for x in service_manifest['versions'].keys()
+        ]
+        self.compatible_service_versions.default = ''
+
+
+class EditTesseractOCRPipelineModelForm(EditContributionBaseForm):
+    def __init__(self, *args, **kwargs):
+        service_manifest = SERVICES['tesseract-ocr-pipeline']
+        super().__init__(*args, **kwargs)
+        self.compatible_service_versions.choices = [('', 'Choose your option')]
+        self.compatible_service_versions.choices += [
+            (x, x) for x in service_manifest['versions'].keys()
+        ]
+        self.compatible_service_versions.default = ''
diff --git a/app/contributions/tesseract_ocr_pipeline_models/json_routes.py b/app/contributions/tesseract_ocr_pipeline_models/json_routes.py
new file mode 100644
index 0000000000000000000000000000000000000000..f90a971ff1888dc5cb839a88abf2d9ad718db54a
--- /dev/null
+++ b/app/contributions/tesseract_ocr_pipeline_models/json_routes.py
@@ -0,0 +1,58 @@
+from flask import abort, current_app, jsonify, request
+from flask_login import login_required, current_user
+from threading import Thread
+from app import db
+from app.decorators import content_negotiation, permission_required
+from app.models import TesseractOCRPipelineModel
+from . import bp
+
+
+@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
+@login_required
+@content_negotiation(produces='application/json')
+def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
+    def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id):
+        with app.app_context():
+            topm = TesseractOCRPipelineModel.query.get(tesseract_ocr_pipeline_model_id)
+            topm.delete()
+            db.session.commit()
+
+    topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
+    if not (topm.user == current_user or current_user.is_administrator()):
+        abort(403)
+    thread = Thread(
+        target=_delete_tesseract_ocr_pipeline_model,
+        args=(current_app._get_current_object(), topm.id)
+    )
+    thread.start()
+    resonse_data = {
+        'message': \
+            f'Tesseract OCR Pipeline Model "{topm.title}" marked for deletion'
+    }
+    response = jsonify(resonse_data)
+    response.status_code = 202
+    return response
+
+
+@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>/is_public', methods=['PUT'])
+@login_required
+@permission_required('CONTRIBUTE')
+@content_negotiation(consumes='application/json', produces='application/json')
+def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_id):
+    is_public = request.json
+    if not isinstance(is_public, bool):
+        abort(400)
+    topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
+    if not (topm.user == current_user or current_user.is_administrator()):
+        abort(403)
+    topm.is_public = is_public
+    db.session.commit()
+    response_data = {
+        'message': (
+            f'Tesseract OCR Pipeline Model "{topm.title}"'
+            f' is now {"public" if is_public else "private"}'
+        )
+    }
+    response = jsonify(response_data)
+    response.status_code = 200
+    return response
diff --git a/app/contributions/tesseract_ocr_pipeline_models/routes.py b/app/contributions/tesseract_ocr_pipeline_models/routes.py
new file mode 100644
index 0000000000000000000000000000000000000000..823e54d9d62d47c164a554fbb3b0834222bb9f5b
--- /dev/null
+++ b/app/contributions/tesseract_ocr_pipeline_models/routes.py
@@ -0,0 +1,80 @@
+from flask import abort, flash, Markup, redirect, render_template, url_for
+from flask_login import login_required, current_user
+from app import db
+from app.models import TesseractOCRPipelineModel
+from . import bp, TEMPLATE_FOLDER
+from .forms import (
+    CreateTesseractOCRPipelineModelForm,
+    EditTesseractOCRPipelineModelForm
+)
+
+
+@bp.route('')
+@login_required
+def tesseract_ocr_pipeline_models():
+    return render_template(
+        f'{TEMPLATE_FOLDER}/tesseract_ocr_pipeline_models.html.j2',
+        title='Tesseract OCR Pipeline Models'
+    )
+
+
+@bp.route('/create', methods=['GET', 'POST'])
+@login_required
+def create_tesseract_ocr_pipeline_model():
+    form = CreateTesseractOCRPipelineModelForm(prefix='create-tesseract-ocr-pipeline-model-form')
+    if form.is_submitted():
+        if not form.validate():
+            response = {'errors': form.errors}
+            return response, 400
+        try:
+            tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.create(
+                form.tesseract_model_file.data,
+                compatible_service_versions=form.compatible_service_versions.data,
+                description=form.description.data,
+                publisher=form.publisher.data,
+                publisher_url=form.publisher_url.data,
+                publishing_url=form.publishing_url.data,
+                publishing_year=form.publishing_year.data,
+                is_public=False,
+                title=form.title.data,
+                version=form.version.data,
+                user=current_user
+            )
+        except OSError:
+            abort(500)
+        db.session.commit()
+        tesseract_ocr_pipeline_model_url = url_for(
+            '.tesseract_ocr_pipeline_model',
+            tesseract_ocr_pipeline_model_id=tesseract_ocr_pipeline_model.id
+        )
+        message = Markup(f'Tesseract OCR Pipeline model "<a href="{tesseract_ocr_pipeline_model_url}">{tesseract_ocr_pipeline_model.title}</a>" created')
+        flash(message)
+        return {}, 201, {'Location': tesseract_ocr_pipeline_model_url}
+    return render_template(
+        f'{TEMPLATE_FOLDER}/create_tesseract_ocr_pipeline_model.html.j2',
+        form=form,
+        title='Create Tesseract OCR Pipeline Model'
+    )
+
+
+@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
+@login_required
+def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
+    tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
+    form = EditTesseractOCRPipelineModelForm(
+        data=tesseract_ocr_pipeline_model.to_json_serializeable(),
+        prefix='edit-tesseract-ocr-pipeline-model-form'
+    )
+    if form.validate_on_submit():
+        form.populate_obj(tesseract_ocr_pipeline_model)
+        if db.session.is_modified(tesseract_ocr_pipeline_model):
+            message = Markup(f'Tesseract OCR Pipeline model "<a href="{tesseract_ocr_pipeline_model.url}">{tesseract_ocr_pipeline_model.title}</a>" updated')
+            flash(message)
+            db.session.commit()
+        return redirect(url_for('.tesseract_ocr_pipeline_models'))
+    return render_template(
+        f'{TEMPLATE_FOLDER}/tesseract_ocr_pipeline_model.html.j2',
+        form=form,
+        tesseract_ocr_pipeline_model=tesseract_ocr_pipeline_model,
+        title=f'{tesseract_ocr_pipeline_model.title} {tesseract_ocr_pipeline_model.version}'
+    )
diff --git a/app/corpora/routes.py b/app/corpora/routes.py
index 4a63a03a4575426283fdfb48a211c5094a2bda79..8a3e5e66f950d079a6177cff80531d6a01aa02ff 100644
--- a/app/corpora/routes.py
+++ b/app/corpora/routes.py
@@ -16,6 +16,7 @@ from threading import Thread
 import os
 from .decorators import corpus_follower_permission_required, corpus_owner_or_admin_required
 from app import db, hashids
+from app.decorators import content_negotiation
 from app.models import (
     Corpus,
     CorpusFile,
@@ -31,102 +32,6 @@ from .forms import (
     UpdateCorpusFileForm
 )
 
-@bp.route('/fake-add')
-@login_required
-def fake_add():
-    pjentsch = User.query.filter_by(username='pjentsch').first()
-    alice = Corpus.query.filter_by(title='Alice in Wonderland').first()
-    pjentsch.follow_corpus(alice)
-    db.session.commit()
-    return ''
-
-
-@bp.route('/<hashid:corpus_id>/is_public', methods=['POST'])
-@login_required
-@corpus_owner_or_admin_required
-def update_corpus_is_public(corpus_id):
-    is_public = request.json
-    if not isinstance(is_public, bool):
-        response = jsonify('The request body must be a boolean')
-        response.status_code = 400
-        abort(response)
-    corpus = Corpus.query.get_or_404(corpus_id)
-    corpus.is_public = is_public
-    db.session.commit()
-    return '', 204
-
-
-@bp.route('/<hashid:corpus_id>/followers/add', methods=['POST'])
-@login_required
-@corpus_owner_or_admin_required
-def add_corpus_followers(corpus_id):
-    usernames = request.json
-    if not (isinstance(usernames, list) or all(isinstance(u, str) for u in usernames)):
-        response = jsonify('The request body must be a list of strings')
-        response.status_code = 400
-        abort(response)
-    corpus = Corpus.query.get_or_404(corpus_id)
-    for username in usernames:
-        user = User.query.filter_by(username=username, is_public=True).first_or_404()
-        user.follow_corpus(corpus)
-    db.session.commit()
-    return '', 204
-
-
-@bp.route('/<hashid:corpus_id>/follow/<token>')
-@login_required
-def follow_corpus(corpus_id, token):
-    corpus = Corpus.query.get_or_404(corpus_id)
-    if current_user.follow_corpus_by_token(token):
-        db.session.commit()
-        flash(f'You are following {corpus.title} now', category='corpus')
-        return redirect(url_for('corpora.corpus', corpus_id=corpus_id))
-    abort(403)    
-
-
-@bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>/unfollow', methods=['POST'])
-@login_required
-def unfollow_corpus(corpus_id, follower_id):
-    corpus = Corpus.query.get_or_404(corpus_id)
-    follower = User.query.get_or_404(follower_id)
-    if not (corpus.user == current_user or follower == current_user or current_user.is_administrator()):
-        abort(403)
-    if not follower.is_following_corpus(corpus):
-        abort(409)  # 'User is not following the corpus'
-    follower.unfollow_corpus(corpus)
-    db.session.commit()
-    flash(f'{follower.username} is not following {corpus.title} anymore', category='corpus')
-    return '', 204
-
-
-@bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>/role', methods=['POST'])
-@corpus_follower_permission_required('UPDATE_FOLLOWER')
-def add_permission(corpus_id, follower_id):
-    corpus_follower_association = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404()
-    if not (corpus_follower_association.corpus.user == current_user or current_user.is_administrator()):
-        abort(403)
-    role_name = request.json.get('role')
-    if role_name is None:
-        abort(400)
-    corpus_follower_role = CorpusFollowerRole.query.filter_by(name=role_name).first_or_404()
-    corpus_follower_association.role = corpus_follower_role
-    db.session.commit()
-    return '', 204
-
-
-@bp.route('/public')
-@login_required
-def public_corpora():
-    corpora = [
-        c.to_json_serializeable()
-        for c in Corpus.query.filter(Corpus.is_public == True).all()
-    ]
-    return render_template(
-        'corpora/public_corpora.html.j2',
-        corpora=corpora,
-        title='Corpora'
-    )
-
 
 @bp.route('/create', methods=['GET', 'POST'])
 @login_required
@@ -154,6 +59,24 @@ def create_corpus():
     )
 
 
+@bp.route('/public')
+@login_required
+def public_corpora():
+    corpora = [
+        c.to_json_serializeable()
+        for c in Corpus.query.filter(Corpus.is_public == True).all()
+    ]
+    return render_template(
+        'corpora/public_corpora.html.j2',
+        corpora=corpora,
+        title='Corpora'
+    )
+
+
+##############################################################################
+# Corpus                                                                     #
+##############################################################################
+#region corpus
 @bp.route('/<hashid:corpus_id>')
 @login_required
 def corpus(corpus_id):
@@ -181,6 +104,18 @@ def corpus(corpus_id):
     abort(403)
 
 
+@bp.route('/<hashid:corpus_id>/analyse')
+@login_required
+@corpus_follower_permission_required('VIEW')
+def analyse_corpus(corpus_id):
+    corpus = Corpus.query.get_or_404(corpus_id)
+    return render_template(
+        'corpora/analyse_corpus.html.j2',
+        corpus=corpus,
+        title=f'Analyse Corpus {corpus.title}'
+    )
+
+
 @bp.route('/<hashid:corpus_id>/generate-corpus-share-link', methods=['GET', 'POST'])
 @login_required
 @corpus_follower_permission_required('GENERATE_SHARE_LINK')
@@ -195,9 +130,34 @@ def generate_corpus_share_link(corpus_id):
     return link
 
 
+@bp.route('/<hashid:corpus_id>/follow/<token>')
+@login_required
+def follow_corpus(corpus_id, token):
+    corpus = Corpus.query.get_or_404(corpus_id)
+    if current_user.follow_corpus_by_token(token):
+        db.session.commit()
+        flash(f'You are following {corpus.title} now', category='corpus')
+        return redirect(url_for('corpora.corpus', corpus_id=corpus_id))
+    abort(403)
+
+
+@bp.route('/import', methods=['GET', 'POST'])
+@login_required
+def import_corpus():
+    abort(503)
+
+
+@bp.route('/<hashid:corpus_id>/export')
+@login_required
+def export_corpus(corpus_id):
+    abort(503)
+
+
+#region json-routes
 @bp.route('/<hashid:corpus_id>', methods=['DELETE'])
 @login_required
 @corpus_owner_or_admin_required
+@content_negotiation(produces='application/json')
 def delete_corpus(corpus_id):
     def _delete_corpus(app, corpus_id):
         with app.app_context():
@@ -208,27 +168,22 @@ def delete_corpus(corpus_id):
     corpus = Corpus.query.get_or_404(corpus_id)
     thread = Thread(
         target=_delete_corpus,
-        args=(current_app._get_current_object(), corpus_id)
+        args=(current_app._get_current_object(), corpus.id)
     )
     thread.start()
-    return {}, 202
-
-
-@bp.route('/<hashid:corpus_id>/analyse')
-@login_required
-@corpus_follower_permission_required('VIEW')
-def analyse_corpus(corpus_id):
-    corpus = Corpus.query.get_or_404(corpus_id)
-    return render_template(
-        'corpora/analyse_corpus.html.j2',
-        corpus=corpus,
-        title=f'Analyse Corpus {corpus.title}'
-    )
+    response_data = {
+        'message': f'Corpus "{corpus.title}" marked for deletion',
+        'category': 'corpus'
+    }
+    response = jsonify(response_data)
+    response.status_code = 200
+    return response
 
 
 @bp.route('/<hashid:corpus_id>/build', methods=['POST'])
 @login_required
 @corpus_owner_or_admin_required
+@content_negotiation(produces='application/json')
 def build_corpus(corpus_id):
     def _build_corpus(app, corpus_id):
         with app.app_context():
@@ -239,18 +194,51 @@ def build_corpus(corpus_id):
     corpus = Corpus.query.get_or_404(corpus_id)
     if not (corpus.user == current_user or current_user.is_administrator()):
         abort(403)
-    # Check if the corpus has corpus files
-    if not corpus.files.all():
-        response = {'errors': {'message': 'Corpus file(s) required'}}
-        return response, 409
+    if len(corpus.files.all()) == 0:
+        abort(409)
     thread = Thread(
         target=_build_corpus,
         args=(current_app._get_current_object(), corpus_id)
     )
     thread.start()
-    return {}, 202
+    response_data = {
+        'message': f'Corpus "{corpus.title}" marked for building',
+        'category': 'corpus'
+    }
+    response = jsonify(response_data)
+    response.status_code = 202
+    return response
 
 
+@bp.route('/<hashid:corpus_id>/is_public', methods=['PUT'])
+@login_required
+@corpus_owner_or_admin_required
+@content_negotiation(consumes='application/json', produces='application/json')
+def update_corpus_is_public(corpus_id):
+    is_public = request.json
+    if not isinstance(is_public, bool):
+        abort(400)
+    corpus = Corpus.query.get_or_404(corpus_id)
+    corpus.is_public = is_public
+    db.session.commit()
+    response_data = {
+        'message': (
+            f'Corpus "{corpus.title}" is now'
+            f' {"public" if is_public else "private"}'
+        ),
+        'category': 'corpus'
+    }
+    response = jsonify(response_data)
+    response.status_code = 200
+    return response
+#endregion json-routes
+#endregion corpus
+
+
+##############################################################################
+# Corpus/Files                                                               #
+##############################################################################
+#region files
 @bp.route('/<hashid:corpus_id>/files/create', methods=['GET', 'POST'])
 @login_required
 @corpus_follower_permission_required('ADD_CORPUS_FILE')
@@ -301,7 +289,7 @@ def create_corpus_file(corpus_id):
 
 @bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['GET', 'POST'])
 @login_required
-@corpus_follower_permission_required('ADD_CORPUS_FILE', 'UPDATE_CORPUS_FILE', 'REMOVE_CORPUS_FILE')
+@corpus_follower_permission_required('UPDATE_CORPUS_FILE')
 def corpus_file(corpus_id, corpus_file_id):
     corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404()
     form = UpdateCorpusFileForm(data=corpus_file.to_json_serializeable())
@@ -322,9 +310,27 @@ def corpus_file(corpus_id, corpus_file_id):
     )
 
 
+@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/download')
+@login_required
+@corpus_follower_permission_required('VIEW')
+def download_corpus_file(corpus_id, corpus_file_id):
+    corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404()
+    if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
+        abort(403)
+    return send_from_directory(
+        os.path.dirname(corpus_file.path),
+        os.path.basename(corpus_file.path),
+        as_attachment=True,
+        attachment_filename=corpus_file.filename,
+        mimetype=corpus_file.mimetype
+    )
+
+
+#region json-routes
 @bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['DELETE'])
 @login_required
 @corpus_follower_permission_required('REMOVE_CORPUS_FILE')
+@content_negotiation(produces='application/json')
 def delete_corpus_file(corpus_id, corpus_file_id):
     def _delete_corpus_file(app, corpus_file_id):
         with app.app_context():
@@ -332,40 +338,83 @@ def delete_corpus_file(corpus_id, corpus_file_id):
             corpus_file.delete()
             db.session.commit()
 
-    corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404()
-    if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
-        abort(403)
+    corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
     thread = Thread(
         target=_delete_corpus_file,
-        args=(current_app._get_current_object(), corpus_file_id)
+        args=(current_app._get_current_object(), corpus_file.id)
     )
     thread.start()
     return {}, 202
-
-
-@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/download')
+#endregion json-routes
+#endregion files
+
+##############################################################################
+# Corpus/Followers                                                           #
+##############################################################################
+#region followers
+#region json-routes
+@bp.route('/<hashid:corpus_id>/followers', methods=['POST'])
 @login_required
-@corpus_follower_permission_required('VIEW')
-def download_corpus_file(corpus_id, corpus_file_id):
-    corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404()
-    if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
-        abort(403)
-    return send_from_directory(
-        os.path.dirname(corpus_file.path),
-        os.path.basename(corpus_file.path),
-        as_attachment=True,
-        attachment_filename=corpus_file.filename,
-        mimetype=corpus_file.mimetype
-    )
+@corpus_owner_or_admin_required
+@content_negotiation(consumes='application/json', produces='application/json')
+def add_corpus_followers(corpus_id):
+    usernames = request.json
+    if not (isinstance(usernames, list) or all(isinstance(u, str) for u in usernames)):
+        abort(400)
+    corpus = Corpus.query.get_or_404(corpus_id)
+    for username in usernames:
+        user = User.query.filter_by(username=username, is_public=True).first_or_404()
+        user.follow_corpus(corpus)
+    db.session.commit()
+    resonse_data = {
+        'message': f'Users are now following "{corpus.title}"',
+        'category': 'corpus'
+    }
+    response = jsonify(resonse_data)
+    response.status_code = 200
+    return response
 
 
-@bp.route('/import', methods=['GET', 'POST'])
+@bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>', methods=['DELETE'])
 @login_required
-def import_corpus():
-    abort(503)
+@content_negotiation(produces='application/json')
+def unfollow_corpus(corpus_id, follower_id):
+    corpus = Corpus.query.get_or_404(corpus_id)
+    follower = User.query.get_or_404(follower_id)
+    if not (corpus.user == current_user or follower == current_user or current_user.is_administrator()):
+        abort(403)
+    if not follower.is_following_corpus(corpus):
+        abort(409)  # 'User is not following the corpus'
+    follower.unfollow_corpus(corpus)
+    db.session.commit()
+    response_data = {
+        'message': \
+            f'"{follower.username}" is not following "{corpus.title}" anymore',
+        'category': 'corpus'
+    }
+    response = jsonify(response_data)
+    response.status_code = 200
+    return response
 
 
-@bp.route('/<hashid:corpus_id>/export')
+@bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>/role', methods=['PUT'])
 @login_required
-def export_corpus(corpus_id):
-    abort(503)
+@corpus_owner_or_admin_required
+@content_negotiation(consumes='application/json', produces='application/json')
+def add_permission(corpus_id, follower_id):
+    role_name = request.json
+    if not isinstance(role_name, str):
+        abort(400)
+    cfr = CorpusFollowerRole.query.filter_by(name=role_name).first_or_404()
+    cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404()
+    cfa.role = cfr
+    db.session.commit()
+    resonse_data = {
+        'message': f'User "{cfa.follower.username}" is now {cfa.role.name}',
+        'category': 'corpus'
+    }
+    response = jsonify(resonse_data)
+    response.status_code = 200
+    return response
+#endregion json-routes
+#endregion followers
diff --git a/app/decorators.py b/app/decorators.py
index 47e6d7491aa1d18eaf1255a80a4f590f1dffa257..21527233721da021280e03dfc680521f4db78963 100644
--- a/app/decorators.py
+++ b/app/decorators.py
@@ -1,7 +1,9 @@
-from flask import abort, current_app
+from flask import abort, current_app, request
 from flask_login import current_user
 from functools import wraps
 from threading import Thread
+from typing import List, Union
+from werkzeug.exceptions import NotAcceptable
 from app.models import Permission
 
 
@@ -61,3 +63,37 @@ def background(f):
         thread.start()
         return thread
     return wrapped
+
+
+def content_negotiation(
+    produces: Union[str, List[str], None] = None,
+    consumes: Union[str, List[str], None] = None
+):
+    def decorator(f):
+        @wraps(f)
+        def decorated_function(*args, **kwargs):
+            provided = request.mimetype
+            if consumes is None:
+                consumeables = None
+            elif isinstance(consumes, str):
+                consumeables = {consumes}
+            elif isinstance(consumes, list) and all(isinstance(x, str) for x in consumes):
+                consumeables = {*consumes}
+            else:
+                raise TypeError()
+            accepted = {*request.accept_mimetypes.values()}
+            if produces is None:
+                produceables = None
+            elif isinstance(produces, str):
+                produceables = {produces}
+            elif isinstance(produces, list) and all(isinstance(x, str) for x in produces):
+                produceables = {*produces}
+            else:
+                raise TypeError()
+            if produceables is not None and len(produceables & accepted) == 0:
+                raise NotAcceptable()
+            if consumeables is not None and provided not in consumeables:
+                raise NotAcceptable()
+            return f(*args, **kwargs)
+        return decorated_function
+    return decorator
diff --git a/app/static/js/Requests/Corpora.js b/app/static/js/Requests/Corpora.js
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/app/static/js/Requests/Requests.js b/app/static/js/Requests/Requests.js
new file mode 100644
index 0000000000000000000000000000000000000000..41f05a7fb422f5464b3919d4a9d32b9b9120c90a
--- /dev/null
+++ b/app/static/js/Requests/Requests.js
@@ -0,0 +1,37 @@
+Requests = {};
+
+Requests.JSONfetch = (input, init={}) => {
+  return new Promise((resolve, reject) => {
+    let fixedInit = {};
+    fixedInit.headers = {};
+    fixedInit.headers['Accept'] = 'application/json';
+    if (init.hasOwnProperty('body')) {
+      fixedInit.headers['Content-Type'] = 'application/json';
+    }
+    fetch(input, Utils.mergeObjectsDeep(init, fixedInit))
+      .then(
+        (response) => {
+          response.json()
+            .then(
+              (json) => {
+                let message = json.message || json;
+                let category = json.category || 'message';
+                app.flash(message, category);
+              },
+              (error) => {
+                app.flash(`[${response.status}]: ${response.statusText}`, 'error');
+              }
+            );
+          if (response.ok) {
+            resolve(response);
+          } else {
+            reject(response);
+          }
+        },
+        (response) => {
+          app.flash('Something went wrong', 'error');
+          reject(response);
+        }
+      );
+  });
+};
diff --git a/app/static/js/Requests/contributions/contributions.js b/app/static/js/Requests/contributions/contributions.js
new file mode 100644
index 0000000000000000000000000000000000000000..2d9cf26a1bcdf0cf4a1c574fead0c26201bf1b43
--- /dev/null
+++ b/app/static/js/Requests/contributions/contributions.js
@@ -0,0 +1,5 @@
+/*****************************************************************************
+* Contributions                                                              *
+* Fetch requests for /contributions routes                                   *
+*****************************************************************************/
+Requests.contributions = {};
diff --git a/app/static/js/Requests/contributions/spacy_nlp_pipeline_models.js b/app/static/js/Requests/contributions/spacy_nlp_pipeline_models.js
new file mode 100644
index 0000000000000000000000000000000000000000..e1422c1e62ec3c624401cf636f24b7b64d16117a
--- /dev/null
+++ b/app/static/js/Requests/contributions/spacy_nlp_pipeline_models.js
@@ -0,0 +1,26 @@
+/*****************************************************************************
+* SpaCy NLP Pipeline Models                                                  *
+* Fetch requests for /contributions/spacy-nlp-pipeline-models routes         *
+*****************************************************************************/
+Requests.contributions.spacy_nlp_pipeline_models = {};
+
+Requests.contributions.spacy_nlp_pipeline_models.entity = {};
+
+Requests.contributions.spacy_nlp_pipeline_models.entity.delete = (spacyNlpPipelineModelId) => {
+  let input = `/contributions/spacy-nlp-pipeline-models/${spacyNlpPipelineModelId}`;
+  let init = {
+    method: 'DELETE'
+  };
+  return Requests.JSONfetch(input, init);
+};
+
+Requests.contributions.spacy_nlp_pipeline_models.entity.isPublic = {};
+
+Requests.contributions.spacy_nlp_pipeline_models.entity.isPublic.update = (spacyNlpPipelineModelId, value) => {
+  let input = `/contributions/spacy-nlp-pipeline-models/${spacyNlpPipelineModelId}/is_public`;
+  let init = {
+    method: 'PUT',
+    body: JSON.stringify(value)
+  };
+  return Requests.JSONfetch(input, init);
+};
diff --git a/app/static/js/Requests/contributions/tesseract_ocr_pipeline_models.js b/app/static/js/Requests/contributions/tesseract_ocr_pipeline_models.js
new file mode 100644
index 0000000000000000000000000000000000000000..13feb42a3d9ee50d1559299a96aed7ff967db738
--- /dev/null
+++ b/app/static/js/Requests/contributions/tesseract_ocr_pipeline_models.js
@@ -0,0 +1,26 @@
+/*****************************************************************************
+* Tesseract OCR Pipeline Models                                              *
+* Fetch requests for /contributions/tesseract-ocr-pipeline-models routes     *
+*****************************************************************************/
+Requests.contributions.tesseract_ocr_pipeline_models = {};
+
+Requests.contributions.tesseract_ocr_pipeline_models.entity = {};
+
+Requests.contributions.tesseract_ocr_pipeline_models.entity.delete = (tesseractOcrPipelineModelId) => {
+  let input = `/contributions/tesseract-ocr-pipeline-models/${tesseractOcrPipelineModelId}`;
+  let init = {
+    method: 'DELETE'
+  };
+  return Requests.JSONfetch(input, init);
+};
+
+Requests.contributions.tesseract_ocr_pipeline_models.entity.isPublic = {};
+
+Requests.contributions.tesseract_ocr_pipeline_models.entity.isPublic.update = (tesseractOcrPipelineModelId, value) => {
+  let input = `/contributions/tesseract-ocr-pipeline-models/${tesseractOcrPipelineModelId}/is_public`;
+  let init = {
+    method: 'PUT',
+    body: JSON.stringify(value)
+  };
+  return Requests.JSONfetch(input, init);
+};
diff --git a/app/static/js/Requests/corpora/corpora.js b/app/static/js/Requests/corpora/corpora.js
new file mode 100644
index 0000000000000000000000000000000000000000..051fb07f4c5a0923113f1025bc95050f55789579
--- /dev/null
+++ b/app/static/js/Requests/corpora/corpora.js
@@ -0,0 +1,78 @@
+/*****************************************************************************
+* Corpora                                                                    *
+* Fetch requests for /corpora routes                                         *
+*****************************************************************************/
+Requests.corpora = {};
+
+Requests.corpora.ent = {};
+
+Requests.corpora.ent.delete = (corpusId) => {
+  let input = `/corpora/${corpusId}`;
+  let init = {
+    method: 'DELETE'
+  };
+  return Requests.JSONfetch(input, init);
+};
+
+Requests.corpora.ent.build = (corpusId) => {
+  let input = `/corpora/${corpusId}/build`;
+  let init = {
+    method: 'POST',
+  };
+  return Requests.JSONfetch(input, init);
+};
+
+Requests.corpora.ent.isPublic = {};
+
+Requests.corpora.ent.isPublic.update = (corpusId, value) => {
+  let input = `/corpora/${corpusId}/is_public`;
+  let init = {
+    method: 'PUT',
+    body: JSON.stringify(value)
+  };
+  return Requests.JSONfetch(input, init);
+};
+
+Requests.corpora.ent.files = {};
+
+Requests.corpora.ent.files.ent = {};
+
+Requests.corpora.ent.files.ent.delete = (corpusId, corpusFileId) => {
+  let input = `/corpora/${corpusId}/files/${corpusFileId}`;
+  let init = {
+    method: 'DELETE',
+  };
+  return Requests.JSONfetch(input, init);
+};
+
+Requests.corpora.ent.followers = {};
+
+Requests.corpora.ent.followers.add = (corpusId, usernames) => {
+  let input = `/corpora/${corpusId}/followers`;
+  let init = {
+    method: 'POST',
+    body: JSON.stringify(usernames)
+  };
+  return Requests.JSONfetch(input, init);
+};
+
+Requests.corpora.ent.followers.ent = {};
+
+Requests.corpora.ent.followers.ent.delete = (corpusId, followerId) => {
+  let input = `/corpora/${corpusId}/followers/${followerId}`;
+  let init = {
+    method: 'DELETE',
+  };
+  return Requests.JSONfetch(input, init);
+};
+
+Requests.corpora.ent.followers.ent.role = {};
+
+Requests.corpora.ent.followers.ent.role.update = (corpusId, followerId, value) => {
+  let input = `/corpora/${corpusId}/followers/${followerId}/role`;
+  let init = {
+    method: 'PUT',
+    body: JSON.stringify(value)
+  };
+  return Requests.JSONfetch(input, init);
+};
diff --git a/app/static/js/RessourceDisplays/CorpusDisplay.js b/app/static/js/ResourceDisplays/CorpusDisplay.js
similarity index 91%
rename from app/static/js/RessourceDisplays/CorpusDisplay.js
rename to app/static/js/ResourceDisplays/CorpusDisplay.js
index ab462e9bf998ee3d092e8d2fab6977ec0165ae15..07f0a9be59f2a71f21f5e713e236c4d32dea4ec7 100644
--- a/app/static/js/RessourceDisplays/CorpusDisplay.js
+++ b/app/static/js/ResourceDisplays/CorpusDisplay.js
@@ -1,16 +1,11 @@
-class CorpusDisplay extends RessourceDisplay {
+class CorpusDisplay extends ResourceDisplay {
   constructor(displayElement) {
     super(displayElement);
     this.corpusId = displayElement.dataset.corpusId;
     this.displayElement
       .querySelector('.action-button[data-action="build-request"]')
       .addEventListener('click', (event) => {
-        Utils.buildCorpusRequest(this.userId, this.corpusId);
-      });
-    this.displayElement
-      .querySelector('.action-button[data-action="delete-request"]')
-      .addEventListener('click', (event) => {
-        Utils.deleteCorpusRequest(this.userId, this.corpusId);
+        Requests.corpora.corpus.build(this.corpusId);
       });
   }
 
diff --git a/app/static/js/RessourceDisplays/JobDisplay.js b/app/static/js/ResourceDisplays/JobDisplay.js
similarity index 99%
rename from app/static/js/RessourceDisplays/JobDisplay.js
rename to app/static/js/ResourceDisplays/JobDisplay.js
index 03c5601b68b13ccc649e5bfd486f88528f3ee0ba..8b94e49b74d7fd2b7f40f653715342b62a684905 100644
--- a/app/static/js/RessourceDisplays/JobDisplay.js
+++ b/app/static/js/ResourceDisplays/JobDisplay.js
@@ -1,4 +1,4 @@
-class JobDisplay extends RessourceDisplay {
+class JobDisplay extends ResourceDisplay {
   constructor(displayElement) {
     super(displayElement);
     this.jobId = this.displayElement.dataset.jobId;
diff --git a/app/static/js/RessourceDisplays/RessourceDisplay.js b/app/static/js/ResourceDisplays/ResourceDisplay.js
similarity index 97%
rename from app/static/js/RessourceDisplays/RessourceDisplay.js
rename to app/static/js/ResourceDisplays/ResourceDisplay.js
index a07c2163be0692e8022e7019e09a98af11b52efe..24a5dec3828f7d6d9c4a63a25ded42c7ab2dcdea 100644
--- a/app/static/js/RessourceDisplays/RessourceDisplay.js
+++ b/app/static/js/ResourceDisplays/ResourceDisplay.js
@@ -1,4 +1,4 @@
-class RessourceDisplay {
+class ResourceDisplay {
   constructor(displayElement) {
     this.displayElement = displayElement;
     this.userId = this.displayElement.dataset.userId;
diff --git a/app/static/js/ResourceLists/SpacyNLPPipelineModelList.js b/app/static/js/ResourceLists/SpacyNLPPipelineModelList.js
index f7901528b1666cd0a06d6397d7d289773d5c4a27..46d3739d940614cec029383884bed9c79b2b05be 100644
--- a/app/static/js/ResourceLists/SpacyNLPPipelineModelList.js
+++ b/app/static/js/ResourceLists/SpacyNLPPipelineModelList.js
@@ -119,7 +119,11 @@ class SpaCyNLPPipelineModelList extends ResourceList {
     let listAction = listActionElement.dataset.listAction;
     switch (listAction) {
       case 'toggle-is-public': {
-        Utils.spaCyNLPPipelineModelToggleIsPublicRequest(this.userId, itemId);
+        let newIsPublicValue = listActionElement.checked;
+        Requests.contributions.spacy_nlp_pipeline_models.entity.isPublic.update(itemId, newIsPublicValue)
+          .catch((response) => {
+            listActionElement.checked = !newIsPublicValue;
+          });
         break;
       }
       default: {
@@ -137,7 +141,37 @@ class SpaCyNLPPipelineModelList extends ResourceList {
     let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
     switch (listAction) {
       case 'delete-request': {
-        Utils.deleteSpaCyNLPPipelineModelRequest(this.userId, itemId);
+        let values = this.listjs.get('id', itemId)[0].values();
+        let modalElement = Utils.HTMLToElement(
+          `
+            <div class="modal">
+              <div class="modal-content">
+                <h4>Confirm SpaCy NLP Pipeline Model deletion</h4>
+                <p>Do you really want to delete the SpaCy NLP Pipeline Model <b>${values.title}</b>? All files will be permanently deleted!</p>
+              </div>
+              <div class="modal-footer">
+                <a class="btn modal-close waves-effect waves-light">Cancel</a>
+                <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
+              </div>
+            </div>
+          `
+        );
+        document.querySelector('#modals').appendChild(modalElement);
+        let modal = M.Modal.init(
+          modalElement,
+          {
+            dismissible: false,
+            onCloseEnd: () => {
+              modal.destroy();
+              modalElement.remove();
+            }
+          }
+        );
+        let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
+        confirmElement.addEventListener('click', (event) => {
+          Requests.contributions.spacy_nlp_pipeline_models.entity.delete(itemId);
+        });
+        modal.open();
         break;
       }
       case 'view': {
diff --git a/app/static/js/ResourceLists/TesseractOCRPipelineModelList.js b/app/static/js/ResourceLists/TesseractOCRPipelineModelList.js
index c5e08b1d64233ba87f3b81bd88d07501c78b3653..9d632b29da88654565017f90338a32d2e643d5f0 100644
--- a/app/static/js/ResourceLists/TesseractOCRPipelineModelList.js
+++ b/app/static/js/ResourceLists/TesseractOCRPipelineModelList.js
@@ -128,7 +128,11 @@ class TesseractOCRPipelineModelList extends ResourceList {
     let listAction = listActionElement.dataset.listAction;
     switch (listAction) {
       case 'toggle-is-public': {
-        Utils.tesseractOCRPipelineModelToggleIsPublicRequest(this.userId, itemId);
+        let newIsPublicValue = listActionElement.checked;
+        Requests.contributions.tesseract_ocr_pipeline_models.entity.isPublic.update(itemId, newIsPublicValue)
+          .catch((response) => {
+            listActionElement.checked = !newIsPublicValue;
+          });
         break;
       }
       default: {
@@ -151,7 +155,37 @@ class TesseractOCRPipelineModelList extends ResourceList {
     let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
     switch (listAction) {
       case 'delete-request': {
-        Utils.deleteTesseractOCRPipelineModelRequest(this.userId, itemId);
+        let values = this.listjs.get('id', itemId)[0].values();
+        let modalElement = Utils.HTMLToElement(
+          `
+            <div class="modal">
+              <div class="modal-content">
+                <h4>Confirm Tesseract OCR Pipeline Model deletion</h4>
+                <p>Do you really want to delete the Tesseract OCR Pipeline Model <b>${values.title}</b>? All files will be permanently deleted!</p>
+              </div>
+              <div class="modal-footer">
+                <a class="btn modal-close waves-effect waves-light">Cancel</a>
+                <a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
+              </div>
+            </div>
+          `
+        );
+        document.querySelector('#modals').appendChild(modalElement);
+        let modal = M.Modal.init(
+          modalElement,
+          {
+            dismissible: false,
+            onCloseEnd: () => {
+              modal.destroy();
+              modalElement.remove();
+            }
+          }
+        );
+        let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
+        confirmElement.addEventListener('click', (event) => {
+          Requests.contributions.tesseract_ocr_pipeline_models.entity.delete(itemId);
+        });
+        modal.open();
         break;
       }
       case 'view': {
diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js
index c5a55e8978fbeaa58e21c7efdf8d98b68391757b..88dadea1dff44847eff5275bdd28629311fc9891 100644
--- a/app/static/js/Utils.js
+++ b/app/static/js/Utils.js
@@ -101,13 +101,21 @@ class Utils {
 
   static updateCorpusFollowerRole(corpusId, followerId, roleName) {
     return new Promise((resolve, reject) => {
-      fetch(`/corpora/${corpusId}/followers/${followerId}/role`, {method: 'POST', headers: {Accept: 'application/json', 'Content-Type': 'application/json'}, body: JSON.stringify({role: roleName})})
+      let fetchRessource = `/corpora/${corpusId}/followers/${followerId}/role`;
+      let fetchOptions = {
+        method: 'POST',
+        headers: {
+          'Accept': 'application/json',
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify({role: roleName})
+      };
+      fetch(fetchRessource, fetchOptions)
         .then(
           (response) => {
             if (response.ok) {
               app.flash('Role updated', 'corpus');
               resolve(response);
-              return;
             } else {
               app.flash(`${response.statusText}`, 'error');
               reject(response);
@@ -179,7 +187,15 @@ class Utils {
 
   static unfollowCorpusRequest(corpusId, followerId) {
     return new Promise((resolve, reject) => {
-      fetch(`/corpora/${corpusId}/followers/${followerId}/unfollow`, {method: 'POST', headers: {Accept: 'application/json'}})
+      let fetchRessource = `/corpora/${corpusId}/followers/${followerId}/unfollow`;
+      let fetchOptions = {
+        method: 'POST',
+        headers: {
+          'Accept': 'application/json',
+          'Content-Type': 'application/json'
+        }
+      };
+      fetch(fetchRessource, fetchOptions)
         .then(
           (response) => {
             if (response.ok) {
@@ -683,23 +699,27 @@ class Utils {
     });
   }
 
-  static tesseractOCRPipelineModelToggleIsPublicRequest(userId, tesseractOCRPipelineModelId, is_public) {
+  static updateTesseractOCRPipelineModelIsPublicRequest(tesseractOCRPipelineModelId, newIsPublicValue) {
     return new Promise((resolve, reject) => {
-      let tesseractOCRPipelineModel;
-      try {
-        tesseractOCRPipelineModel = app.data.users[userId].tesseract_ocr_pipeline_models[tesseractOCRPipelineModelId];
-      } catch (error) {
-        tesseractOCRPipelineModel = {};
-      }
-    
-      fetch(`/contributions/tesseract-ocr-pipeline-models/${tesseractOCRPipelineModelId}/toggle-public-status`, {method: 'POST', headers: {Accept: 'application/json'}})
+      let fetchRessource = `/contributions/tesseract-ocr-pipeline-models/${tesseractOCRPipelineModelId}/is_public`;
+      let fetchOptions = {
+        method: 'PUT',
+        headers: {
+          'Accept': 'application/json',
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify(newIsPublicValue)
+      };
+      fetch(fetchRessource, fetchOptions)
         .then(
           (response) => {
-            if (response.status === 403) {
-              app.flash('Forbidden', 'error');
+            if (response.ok) {
+              response.json().then((data) => {app.flash(data);});
+              resolve(response);
+            } else {
+              app.flash(`${response.statusText}`, 'error');
               reject(response);
             }
-            resolve(response);
           },
           (response) => {
             app.flash('Something went wrong', 'error');
@@ -709,23 +729,27 @@ class Utils {
     });
   }
 
-  static spaCyNLPPipelineModelToggleIsPublicRequest(userId, spaCyNLPPipelineModelId) {
+  static updateSpaCyNLPPipelineModelIsPublicRequest(SpaCyNLPPipelineModelId, newIsPublicValue) {
     return new Promise((resolve, reject) => {
-      let spaCyNLPPipelineModel;
-      try {
-        spaCyNLPPipelineModel = app.data.users[userId].spacy_nlp_pipeline_models[spaCyNLPPipelineModelId];
-      } catch (error) {
-        spaCyNLPPipelineModel = {};
-      }
-
-      fetch(`/contributions/spacy-nlp-pipeline-models/${spaCyNLPPipelineModelId}/toggle-public-status`, {method: 'POST', headers: {Accept: 'application/json'}})
+      let fetchRessource = `/contributions/spacy-nlp-pipeline-models/${SpaCyNLPPipelineModelId}/is_public`;
+      let fetchOptions = {
+        method: 'PUT',
+        headers: {
+          'Accept': 'application/json',
+          'Content-Type': 'application/json'
+        },
+        body: JSON.stringify(newIsPublicValue)
+      };
+      fetch(fetchRessource, fetchOptions)
         .then(
           (response) => {
-            if (response.status === 403) {
-              app.flash('Forbidden', 'error');
+            if (response.ok) {
+              response.json().then((data) => {app.flash(data);});
+              resolve(response);
+            } else {
+              app.flash(`${response.statusText}`, 'error');
               reject(response);
             }
-            resolve(response);
           },
           (response) => {
             app.flash('Something went wrong', 'error');
diff --git a/app/templates/_scripts.html.j2 b/app/templates/_scripts.html.j2
index 18cde5b8e3ca44056c44f72ef0eff6ef1d7efc04..6fef8d1a0274f0bdbce79af8b09396536387efca 100644
--- a/app/templates/_scripts.html.j2
+++ b/app/templates/_scripts.html.j2
@@ -6,18 +6,37 @@
   output='gen/app.%(version)s.js',
   'js/App.js',
   'js/Utils.js',
-  'js/Forms/Form.js',
-  'js/Forms/CreateCorpusFileForm.js',
-  'js/Forms/CreateJobForm.js',
-  'js/Forms/CreateContributionForm.js',
   'js/CorpusAnalysis/CQiClient.js',
   'js/CorpusAnalysis/CorpusAnalysisApp.js',
   'js/CorpusAnalysis/CorpusAnalysisConcordance.js',
   'js/CorpusAnalysis/CorpusAnalysisReader.js',
   'js/CorpusAnalysis/QueryBuilder.js',
-  'js/RessourceDisplays/RessourceDisplay.js',
-  'js/RessourceDisplays/CorpusDisplay.js',
-  'js/RessourceDisplays/JobDisplay.js',
+  'js/XMLtoObject.js'
+%}
+<script src="{{ ASSET_URL }}"></script>
+{%- endassets %}
+{%- assets
+  filters='rjsmin',
+  output='gen/Forms.%(version)s.js',
+  'js/Forms/Form.js',
+  'js/Forms/CreateCorpusFileForm.js',
+  'js/Forms/CreateJobForm.js',
+  'js/Forms/CreateContributionForm.js'
+%}
+<script src="{{ ASSET_URL }}"></script>
+{%- endassets %}
+{%- assets
+  filters='rjsmin',
+  output='gen/ResourceDisplays.%(version)s.js',
+  'js/ResourceDisplays/ResourceDisplay.js',
+  'js/ResourceDisplays/CorpusDisplay.js',
+  'js/ResourceDisplays/JobDisplay.js'
+%}
+<script src="{{ ASSET_URL }}"></script>
+{%- endassets %}
+{%- assets
+  filters='rjsmin',
+  output='gen/ResourceLists.%(version)s.js',
   'js/ResourceLists/ResourceList.js',
   'js/ResourceLists/CorpusFileList.js',
   'js/ResourceLists/PublicCorpusFileList.js',
@@ -31,8 +50,18 @@
   'js/ResourceLists/TesseractOCRPipelineModelList.js',
   'js/ResourceLists/UserList.js',
   'js/ResourceLists/AdminUserList.js',
-  'js/ResourceLists/CorpusFollowerList.js',
-  'js/XMLtoObject.js'
+  'js/ResourceLists/CorpusFollowerList.js'
+%}
+<script src="{{ ASSET_URL }}"></script>
+{%- endassets %}
+{%- assets
+  filters='rjsmin',
+  output='gen/Requests.%(version)s.js',
+  'js/Requests/Requests.js',
+  'js/Requests/contributions/contributions.js',
+  'js/Requests/contributions/spacy_nlp_pipeline_models.js',
+  'js/Requests/contributions/tesseract_ocr_pipeline_models.js',
+  'js/Requests/Corpora.js'
 %}
 <script src="{{ ASSET_URL }}"></script>
 {%- endassets %}
diff --git a/app/templates/contributions/contributions.html.j2 b/app/templates/contributions/contributions.html.j2
index bdbcb10c42addf50fa31f220f682fb104dc12b68..efefcbbb2385915d9d3e8f4de39f3d1debe46027 100644
--- a/app/templates/contributions/contributions.html.j2
+++ b/app/templates/contributions/contributions.html.j2
@@ -1,6 +1,5 @@
 {% extends "base.html.j2" %}
 {% import "materialize/wtf.html.j2" as wtf %}
-{% from "contributions/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
@@ -11,7 +10,7 @@
 
     <div class="col s4">
       <div class="card extension-selector hoverable service-color" data-service="tesseract-ocr-pipeline">
-        <a href="{{ url_for('.tesseract_ocr_pipeline_models') }}" style="position: absolute; width: 100%; height: 100%;"></a>
+        <a href="{{ url_for('.tesseract_ocr_pipeline_models.tesseract_ocr_pipeline_models') }}" style="position: absolute; width: 100%; height: 100%;"></a>
         <div class="card-content">
           <span class="card-title" data-service="tesseract-ocr-pipeline"><i class="nopaque-icons service-icons" data-service="tesseract-ocr-pipeline"></i>Tesseract OCR Pipeline Models</span>
           <p>Here you can see and edit the models that you have created. You can also create new models.</p>
@@ -21,7 +20,7 @@
 
     <div class="col s4">
       <div class="card extension-selector hoverable service-color" data-service="spacy-nlp-pipeline">
-      <a href="{{ url_for('.spacy_nlp_pipeline_models') }}" style="position: absolute; width: 100%; height: 100%;"></a>
+        <a href="{{ url_for('.spacy_nlp_pipeline_models.spacy_nlp_pipeline_models') }}" style="position: absolute; width: 100%; height: 100%;"></a>
         <div class="card-content">
           <span class="card-title"><i class="nopaque-icons service-icons" data-service="spacy-nlp-pipeline"></i>SpaCy NLP Pipeline Models</span>
           <p>Here you can see and edit the models that you have created. You can also create new models.</p>
diff --git a/app/templates/contributions/create_spacy_nlp_pipeline_model.html.j2 b/app/templates/contributions/spacy_nlp_pipeline_models/create_spacy_nlp_pipeline_model.html.j2
similarity index 97%
rename from app/templates/contributions/create_spacy_nlp_pipeline_model.html.j2
rename to app/templates/contributions/spacy_nlp_pipeline_models/create_spacy_nlp_pipeline_model.html.j2
index e17ac9e58cec63afebbb6a307774724f0d72cdbe..091c61ad22a8df5e0ffd522cb46b67eaa04090ae 100644
--- a/app/templates/contributions/create_spacy_nlp_pipeline_model.html.j2
+++ b/app/templates/contributions/spacy_nlp_pipeline_models/create_spacy_nlp_pipeline_model.html.j2
@@ -1,6 +1,5 @@
 {% extends "base.html.j2" %}
 {% import "materialize/wtf.html.j2" as wtf %}
-{% from "contributions/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block main_attribs %} class="service-scheme" data-service="spacy-nlp-pipeline"{% endblock main_attribs %}
 
diff --git a/app/templates/contributions/spacy_nlp_pipeline_model.html.j2 b/app/templates/contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_model.html.j2
similarity index 96%
rename from app/templates/contributions/spacy_nlp_pipeline_model.html.j2
rename to app/templates/contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_model.html.j2
index 32f27303455c9fbde91dbbb10dbf9d73d1c963b0..467effc95ac9b1b71f6ab23c192802a77f9ea4ad 100644
--- a/app/templates/contributions/spacy_nlp_pipeline_model.html.j2
+++ b/app/templates/contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_model.html.j2
@@ -1,6 +1,5 @@
 {% extends "base.html.j2" %}
 {% import "materialize/wtf.html.j2" as wtf %}
-{% from "contributions/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block main_attribs %} class="service-scheme" data-service="spacy-nlp-pipeline"{% endblock main_attribs %}
 
diff --git a/app/templates/contributions/spacy_nlp_pipeline_models.html.j2 b/app/templates/contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_models.html.j2
similarity index 91%
rename from app/templates/contributions/spacy_nlp_pipeline_models.html.j2
rename to app/templates/contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_models.html.j2
index f3c7a40ee113b0c49893f6fedc0d8b5312082a6f..b57507beb4dc39e4486534ce78fd69a4c7d9a7e1 100644
--- a/app/templates/contributions/spacy_nlp_pipeline_models.html.j2
+++ b/app/templates/contributions/spacy_nlp_pipeline_models/spacy_nlp_pipeline_models.html.j2
@@ -1,6 +1,5 @@
 {% extends "base.html.j2" %}
 {% import "materialize/wtf.html.j2" as wtf %}
-{% from "contributions/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
diff --git a/app/templates/contributions/create_tesseract_ocr_pipeline_model.html.j2 b/app/templates/contributions/tesseract_ocr_pipeline_models/create_tesseract_ocr_pipeline_model.html.j2
similarity index 98%
rename from app/templates/contributions/create_tesseract_ocr_pipeline_model.html.j2
rename to app/templates/contributions/tesseract_ocr_pipeline_models/create_tesseract_ocr_pipeline_model.html.j2
index ecede20a08d2a8ce5cc1b46e9023808c55191747..43ad0a136437deaae1ac299c0b13a2f8ca64b2df 100644
--- a/app/templates/contributions/create_tesseract_ocr_pipeline_model.html.j2
+++ b/app/templates/contributions/tesseract_ocr_pipeline_models/create_tesseract_ocr_pipeline_model.html.j2
@@ -1,6 +1,5 @@
 {% extends "base.html.j2" %}
 {% import "materialize/wtf.html.j2" as wtf %}
-{% from "contributions/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block main_attribs %} class="service-scheme" data-service="tesseract-ocr-pipeline"{% endblock main_attribs %}
 
diff --git a/app/templates/contributions/tesseract_ocr_pipeline_model.html.j2 b/app/templates/contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_model.html.j2
similarity index 95%
rename from app/templates/contributions/tesseract_ocr_pipeline_model.html.j2
rename to app/templates/contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_model.html.j2
index 02322d8a9b884e30274a1b40adf0f482f0118112..c7cc2d5c8cf9fe1566f768556a01cc17d59b1802 100644
--- a/app/templates/contributions/tesseract_ocr_pipeline_model.html.j2
+++ b/app/templates/contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_model.html.j2
@@ -1,6 +1,5 @@
 {% extends "base.html.j2" %}
 {% import "materialize/wtf.html.j2" as wtf %}
-{% from "contributions/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block main_attribs %} class="service-scheme" data-service="tesseract-ocr-pipeline"{% endblock main_attribs %}
 
diff --git a/app/templates/contributions/tesseract_ocr_pipeline_models.html.j2 b/app/templates/contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_models.html.j2
similarity index 91%
rename from app/templates/contributions/tesseract_ocr_pipeline_models.html.j2
rename to app/templates/contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_models.html.j2
index 3d43d72769ba5204c528102294648ec5e06524bf..a2f18c2a4b4a4f3896e0a1c17d325433c4436e3f 100644
--- a/app/templates/contributions/tesseract_ocr_pipeline_models.html.j2
+++ b/app/templates/contributions/tesseract_ocr_pipeline_models/tesseract_ocr_pipeline_models.html.j2
@@ -1,6 +1,5 @@
 {% extends "base.html.j2" %}
 {% import "materialize/wtf.html.j2" as wtf %}
-{% from "contributions/_breadcrumbs.html.j2" import breadcrumbs with context %}
 
 {% block page_content %}
 <div class="container">
diff --git a/app/templates/corpora/corpus.html.j2 b/app/templates/corpora/corpus.html.j2
index 172dac9020103fb85b1c95de5cacede5edfc8027..3174713f7abf61d6b79c4443c414684d1e4dc70f 100644
--- a/app/templates/corpora/corpus.html.j2
+++ b/app/templates/corpora/corpus.html.j2
@@ -68,7 +68,7 @@
               <a class="btn waves-effect waves-light modal-trigger" href="#publishing-modal" style="width: 100%;"><i class="material-icons left">publish</i>Publishing</a>
             </div>
             <div class="col s12 l6" style="padding: 5px 2.5px 0 2.5px;">
-              <a class="action-button btn red waves-effect waves-light" data-action="delete-request" style="width: 100%;"><i class="material-icons left">delete</i>Delete</a>
+              <a class="btn red waves-effect waves-light modal-trigger" href="#delete-modal" style="width: 100%;"><i class="material-icons left">delete</i>Delete</a>
             </div>
           </div>
           <span class="card-title">Social</span>
@@ -131,6 +131,17 @@
   </div>
 </div>
 
+<div class="modal" id="delete-modal">
+  <div class="modal-content">
+    <h4>Confirm Corpus deletion</h4>
+    <p>Do you really want to delete the Corpus <b>{{ corpus.title }}</b>? All files will be permanently deleted!</p>
+  </div>
+  <div class="modal-footer">
+    <a class="btn modal-close waves-effect waves-light">Cancel</a>
+    <a class="btn modal-close red waves-effect waves-light" id="delete-modal-delete-button">Delete</a>
+  </div>
+</div>
+
 <div class="modal no-autoinit" id="invite-user-modal">
   <div class="modal-content">
     <h4>Invite a nopaque user by username</h4>
@@ -215,13 +226,21 @@
   let publishingModalIsPublicSwitchElement = document.querySelector('#publishing-modal-is-public-switch');
   publishingModalIsPublicSwitchElement.addEventListener('change', (event) => {
     let newIsPublic = publishingModalIsPublicSwitchElement.checked;
-    Utils.updateCorpusIsPublicRequest(corpusId, newIsPublic)
+    Requests.corpora.corpus.isPublic.update(corpusId, newIsPublic)
       .catch((response) => {
         publishingModalIsPublicSwitchElement.checked = !newIsPublic;
       });
   });
   // #endregion publishing_modal_js
 
+  // #region delete_modal_js
+  let deleteModalDeleteButtonElement = document.querySelector('#delete-modal-delete-button');
+  deleteModalDeleteButtonElement.addEventListener('click', (event) => {
+    Requests.corpora.corpus.delete(corpusId)
+      .then((response) => {window.location.href = '/dashboard';});
+  });
+  // #endregion delete_modal_js
+
   // #region invite_user_modal_js
   let inviteUserModalElement = document.querySelector('#invite-user-modal');
   let inviteUserModalSearchElement = document.querySelector('#invite-user-modal-search');