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 f9e4d2c5a0d428c822bf66506dece49b027e5955..6d8b9cc3ab62b318b9c9a61fe4442e2698c9ec87 100644
--- a/app/contributions/routes.py
+++ b/app/contributions/routes.py
@@ -1,369 +1,12 @@
-from flask import (
-    abort,
-    current_app,
-    flash,
-    jsonify,
-    Markup,
-    redirect,
-    render_template,
-    request,
-    url_for
-)
-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, 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'
     )
-
-
-##############################################################################
-# SpaCy NLP Pipeline Models                                                  #
-##############################################################################
-#region spacy-nlp-pipeline-models
-@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/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>', 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}'
-    )
-
-
-#region json-routes
-@bp.route('/spacy-nlp-pipeline-models/<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(spacy_nlp_pipeline_model_id)
-    if snpm is None:
-        resonse_data = {
-            'message': (
-                'SpaCy NLP Pipeline Model with id'
-                f' "{spacy_nlp_pipeline_model_id}" not found'
-            ),
-            'category': 'error'
-        }
-        response = jsonify(resonse_data)
-        response.status_code = 404
-        return response
-    if not (snpm.user == current_user or current_user.is_administrator()):
-        resonse_data = {
-            'message': f'You are not allowed to delete "{snpm.title}"',
-            'category': 'error'
-        }
-        response = jsonify(resonse_data)
-        response.status_code = 403
-        return response
-    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('/spacy-nlp-pipeline-models/<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):
-        resonse_data = {
-            'message': 'Request body must be a boolean',
-            'category': 'error'
-        }
-        response = jsonify(resonse_data)
-        response.status_code = 400
-        return response
-    snpm = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
-    if snpm is None:
-        resonse_data = {
-            'message': (
-                'SpaCy NLP Pipeline Model with id'
-                f' "{spacy_nlp_pipeline_model_id}" not found'
-            ),
-            'category': 'error'
-        }
-        response = jsonify(resonse_data)
-        response.status_code = 404
-        return response
-    if not (snpm.user == current_user or current_user.is_administrator()):
-        resonse_data = {
-            'message': f'You are not allowed to delete "{snpm.title}"',
-            'category': 'error'
-        }
-        response = jsonify(resonse_data)
-        response.status_code = 403
-        return response
-    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
-#endregion json-routes
-#endregion spacy-nlp-pipeline-models
-
-##############################################################################
-# Tesseract OCR Pipeline Models                                              #
-##############################################################################
-#region tesseract-ocr-pipeline-models
-@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/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>', 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}'
-    )
-
-
-#region json-routes
-@bp.route('/tesseract-ocr-pipeline-models/<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(tesseract_ocr_pipeline_model_id)
-    if topm is None:
-        resonse_data = {
-            'message': (
-                'Tesseract OCR Pipeline Model with id'
-                f' "{tesseract_ocr_pipeline_model_id}" not found'
-            ),
-            'category': 'error'
-        }
-        response = jsonify(resonse_data)
-        response.status_code = 404
-        return response
-    if not (topm.user == current_user or current_user.is_administrator()):
-        resonse_data = {
-            'message': f'You are not allowed to delete "{topm.title}"',
-            'category': 'error'
-        }
-        response = jsonify(resonse_data)
-        response.status_code = 403
-        return response
-    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('/tesseract-ocr-pipeline-models/<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):
-        resonse_data = {
-            'message': 'Request body must be a boolean',
-            'category': 'error'
-        }
-        response = jsonify(resonse_data)
-        response.status_code = 400
-        return response
-    topm = TesseractOCRPipelineModel.query.get(tesseract_ocr_pipeline_model_id)
-    if topm is None:
-        resonse_data = {
-            'message': (
-                'Tesseract OCR Pipeline Model with id'
-                f' "{tesseract_ocr_pipeline_model_id}" not found'
-            ),
-            'category': 'error'
-        }
-        response = jsonify(resonse_data)
-        response.status_code = 404
-        return response
-    if not (topm.user == current_user or current_user.is_administrator()):
-        resonse_data = {
-            'message': f'You are not allowed to delete "{topm.title}"',
-            'category': 'error'
-        }
-        response = jsonify(resonse_data)
-        response.status_code = 403
-        return response
-    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
-#endregion json-routes
-#endregion tesseract-ocr-pipeline-models
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/templates/contributions/contributions.html.j2 b/app/templates/contributions/contributions.html.j2
index bdbcb10c42addf50fa31f220f682fb104dc12b68..99147b56f247447160b2af8dae8ca2c02f886262 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">