diff --git a/uffd/mail/templates/mail_list.html b/uffd/mail/templates/mail/list.html
similarity index 100%
rename from uffd/mail/templates/mail_list.html
rename to uffd/mail/templates/mail/list.html
diff --git a/uffd/mail/templates/mail.html b/uffd/mail/templates/mail/show.html
similarity index 100%
rename from uffd/mail/templates/mail.html
rename to uffd/mail/templates/mail/show.html
diff --git a/uffd/mail/views.py b/uffd/mail/views.py
index 2aaebf559f44465c17ac6bde67796047b48d86e9..2a7a143247c0e2e06a57ab6cbd002d8b4b8b9e5e 100644
--- a/uffd/mail/views.py
+++ b/uffd/mail/views.py
@@ -21,7 +21,7 @@ def mail_acl_check():
 @bp.route("/")
 @register_navbar('Mail', icon='envelope', blueprint=bp, visible=mail_acl_check)
 def index():
-	return render_template('mail_list.html', mails=Mail.query.all())
+	return render_template('mail/list.html', mails=Mail.query.all())
 
 @bp.route("/<uid>")
 @bp.route("/new")
@@ -29,7 +29,7 @@ def show(uid=None):
 	mail = Mail()
 	if uid is not None:
 		mail = Mail.query.filter_by(uid=uid).first_or_404()
-	return render_template('mail.html', mail=mail)
+	return render_template('mail/show.html', mail=mail)
 
 @bp.route("/<uid>/update", methods=['POST'])
 @bp.route("/new", methods=['POST'])
diff --git a/uffd/mfa/templates/auth.html b/uffd/mfa/templates/mfa/auth.html
similarity index 100%
rename from uffd/mfa/templates/auth.html
rename to uffd/mfa/templates/mfa/auth.html
diff --git a/uffd/mfa/templates/disable.html b/uffd/mfa/templates/mfa/disable.html
similarity index 100%
rename from uffd/mfa/templates/disable.html
rename to uffd/mfa/templates/mfa/disable.html
diff --git a/uffd/mfa/templates/setup.html b/uffd/mfa/templates/mfa/setup.html
similarity index 100%
rename from uffd/mfa/templates/setup.html
rename to uffd/mfa/templates/mfa/setup.html
diff --git a/uffd/mfa/templates/setup_recovery.html b/uffd/mfa/templates/mfa/setup_recovery.html
similarity index 100%
rename from uffd/mfa/templates/setup_recovery.html
rename to uffd/mfa/templates/mfa/setup_recovery.html
diff --git a/uffd/mfa/templates/setup_totp.html b/uffd/mfa/templates/mfa/setup_totp.html
similarity index 100%
rename from uffd/mfa/templates/setup_totp.html
rename to uffd/mfa/templates/mfa/setup_totp.html
diff --git a/uffd/mfa/views.py b/uffd/mfa/views.py
index f7a3e239c6394471543baf940dbbaa9c01de45f4..d384b95c0019a9d4eca64909e8d4391a2df9beeb 100644
--- a/uffd/mfa/views.py
+++ b/uffd/mfa/views.py
@@ -21,12 +21,12 @@ def setup():
 	recovery_methods = RecoveryCodeMethod.query.filter_by(dn=user.dn).all()
 	totp_methods = TOTPMethod.query.filter_by(dn=user.dn).all()
 	webauthn_methods = WebauthnMethod.query.filter_by(dn=user.dn).all()
-	return render_template('setup.html', totp_methods=totp_methods, webauthn_methods=webauthn_methods, recovery_methods=recovery_methods)
+	return render_template('mfa/setup.html', totp_methods=totp_methods, webauthn_methods=webauthn_methods, recovery_methods=recovery_methods)
 
 @bp.route('/setup/disable', methods=['GET'])
 @login_required()
 def disable():
-	return render_template('disable.html')
+	return render_template('mfa/disable.html')
 
 @bp.route('/setup/disable', methods=['POST'])
 @login_required()
@@ -65,7 +65,7 @@ def setup_recovery():
 		methods.append(method)
 		db.session.add(method)
 	db.session.commit()
-	return render_template('setup_recovery.html', methods=methods)
+	return render_template('mfa/setup_recovery.html', methods=methods)
 
 @bp.route('/setup/totp', methods=['GET'])
 @login_required()
@@ -73,7 +73,7 @@ def setup_totp():
 	user = get_current_user()
 	method = TOTPMethod(user)
 	session['mfa_totp_key'] = method.key
-	return render_template('setup_totp.html', method=method, name=request.values['name'])
+	return render_template('mfa/setup_totp.html', method=method, name=request.values['name'])
 
 @bp.route('/setup/totp', methods=['POST'])
 @login_required()
@@ -218,7 +218,7 @@ def auth():
 		session['user_mfa'] = True
 	if session.get('user_mfa'):
 		return redirect(request.values.get('ref', url_for('index')))
-	return render_template('auth.html', ref=request.values.get('ref'), totp_methods=totp_methods,
+	return render_template('mfa/auth.html', ref=request.values.get('ref'), totp_methods=totp_methods,
 			webauthn_methods=webauthn_methods, recovery_methods=recovery_methods)
 
 @bp.route('/auth', methods=['POST'])
diff --git a/uffd/oauth2/templates/error.html b/uffd/oauth2/templates/oauth2/error.html
similarity index 100%
rename from uffd/oauth2/templates/error.html
rename to uffd/oauth2/templates/oauth2/error.html
diff --git a/uffd/oauth2/templates/logout.html b/uffd/oauth2/templates/oauth2/logout.html
similarity index 100%
rename from uffd/oauth2/templates/logout.html
rename to uffd/oauth2/templates/oauth2/logout.html
diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py
index 3ca1b9d96bde823d65141781e1302d0e7b3c2108..b712a82cc97e5d1c318c1ff5445275183878bcc0 100644
--- a/uffd/oauth2/views.py
+++ b/uffd/oauth2/views.py
@@ -117,7 +117,7 @@ def error():
 	args = dict(request.values)
 	err = args.pop('error', 'unknown')
 	error_description = args.pop('error_description', '')
-	return render_template('error.html', error=err, error_description=error_description, args=args)
+	return render_template('oauth2/error.html', error=err, error_description=error_description, args=args)
 
 @bp.app_url_defaults
 def inject_logout_params(endpoint, values):
@@ -131,4 +131,4 @@ def logout():
 		return redirect(request.values.get('ref', '/'))
 	client_ids = request.values['client_ids'].split(',')
 	clients = [OAuth2Client.from_id(client_id) for client_id in client_ids]
-	return render_template('logout.html', clients=clients)
+	return render_template('oauth2/logout.html', clients=clients)
diff --git a/uffd/role/templates/role_list.html b/uffd/role/templates/role/list.html
similarity index 100%
rename from uffd/role/templates/role_list.html
rename to uffd/role/templates/role/list.html
diff --git a/uffd/role/templates/role.html b/uffd/role/templates/role/show.html
similarity index 100%
rename from uffd/role/templates/role.html
rename to uffd/role/templates/role/show.html
diff --git a/uffd/role/views.py b/uffd/role/views.py
index be6ecff5897fe2279f547da1ee9aeb00087ba2d4..d2c3d846a239bd31364005ff68d3611215369d7d 100644
--- a/uffd/role/views.py
+++ b/uffd/role/views.py
@@ -49,18 +49,18 @@ def role_acl_check():
 @bp.route("/")
 @register_navbar('Roles', icon='key', blueprint=bp, visible=role_acl_check)
 def index():
-	return render_template('role_list.html', roles=Role.query.all())
+	return render_template('role/list.html', roles=Role.query.all())
 
 @bp.route("/new")
 def new():
-	return render_template('role.html', role=Role(), groups=Group.query.all(), roles=Role.query.all())
+	return render_template('role/show.html', role=Role(), groups=Group.query.all(), roles=Role.query.all())
 
 @bp.route("/<int:roleid>")
 def show(roleid=None):
 	# prefetch all users so the ldap orm can cache them and doesn't run one ldap query per user
 	User.query.all()
 	role = Role.query.filter_by(id=roleid).one()
-	return render_template('role.html', role=role, groups=Group.query.all(), roles=Role.query.all())
+	return render_template('role/show.html', role=role, groups=Group.query.all(), roles=Role.query.all())
 
 @bp.route("/<int:roleid>/update", methods=['POST'])
 @bp.route("/new", methods=['POST'])
diff --git a/uffd/selfservice/templates/forgot_password.html b/uffd/selfservice/templates/selfservice/forgot_password.html
similarity index 100%
rename from uffd/selfservice/templates/forgot_password.html
rename to uffd/selfservice/templates/selfservice/forgot_password.html
diff --git a/uffd/selfservice/templates/mailverification.mail.txt b/uffd/selfservice/templates/selfservice/mailverification.mail.txt
similarity index 100%
rename from uffd/selfservice/templates/mailverification.mail.txt
rename to uffd/selfservice/templates/selfservice/mailverification.mail.txt
diff --git a/uffd/selfservice/templates/newuser.mail.txt b/uffd/selfservice/templates/selfservice/newuser.mail.txt
similarity index 100%
rename from uffd/selfservice/templates/newuser.mail.txt
rename to uffd/selfservice/templates/selfservice/newuser.mail.txt
diff --git a/uffd/selfservice/templates/passwordreset.mail.txt b/uffd/selfservice/templates/selfservice/passwordreset.mail.txt
similarity index 100%
rename from uffd/selfservice/templates/passwordreset.mail.txt
rename to uffd/selfservice/templates/selfservice/passwordreset.mail.txt
diff --git a/uffd/selfservice/templates/self.html b/uffd/selfservice/templates/selfservice/self.html
similarity index 100%
rename from uffd/selfservice/templates/self.html
rename to uffd/selfservice/templates/selfservice/self.html
diff --git a/uffd/selfservice/templates/set_password.html b/uffd/selfservice/templates/selfservice/set_password.html
similarity index 100%
rename from uffd/selfservice/templates/set_password.html
rename to uffd/selfservice/templates/selfservice/set_password.html
diff --git a/uffd/selfservice/views.py b/uffd/selfservice/views.py
index 44556f363fb5273fe2decd47296e6d6e0cec588a..6bc07b259c4c8a1118a8eaac02aa77c40fac44eb 100644
--- a/uffd/selfservice/views.py
+++ b/uffd/selfservice/views.py
@@ -23,7 +23,7 @@ reset_ratelimit = Ratelimit('passwordreset', 1*60*60, 3)
 @register_navbar('Selfservice', icon='portrait', blueprint=bp, visible=is_valid_session)
 @login_required()
 def index():
-	return render_template('self.html', user=get_current_user())
+	return render_template('selfservice/self.html', user=get_current_user())
 
 @bp.route("/update", methods=(['POST']))
 @csrf_protect(blueprint=bp)
@@ -57,7 +57,7 @@ def update():
 @bp.route("/passwordreset", methods=(['GET', 'POST']))
 def forgot_password():
 	if request.method == 'GET':
-		return render_template('forgot_password.html')
+		return render_template('selfservice/forgot_password.html')
 
 	loginname = request.values['loginname']
 	mail = request.values['mail']
@@ -87,17 +87,17 @@ def token_password(token):
 			db.session.commit()
 		return redirect(url_for('session.login'))
 	if request.method == 'GET':
-		return render_template('set_password.html', token=token)
+		return render_template('selfservice/set_password.html', token=token)
 	if not request.values['password1']:
 		flash('You need to set a password, please try again.')
-		return render_template('set_password.html', token=token)
+		return render_template('selfservice/set_password.html', token=token)
 	if not request.values['password1'] == request.values['password2']:
 		flash('Passwords do not match, please try again.')
-		return render_template('set_password.html', token=token)
+		return render_template('selfservice/set_password.html', token=token)
 	user = User.query.filter_by(loginname=dbtoken.loginname).one()
 	if not user.set_password(request.values['password1']):
 		flash('Password ist not valid, please try again.')
-		return render_template('set_password.html', token=token)
+		return render_template('selfservice/set_password.html', token=token)
 	db.session.delete(dbtoken)
 	flash('New password set')
 	ldap.session.commit()
@@ -137,7 +137,7 @@ def send_mail_verification(loginname, newmail):
 	user = User.query.filter_by(loginname=loginname).one()
 
 	msg = EmailMessage()
-	msg.set_content(render_template('mailverification.mail.txt', user=user, token=token.token))
+	msg.set_content(render_template('selfservice/mailverification.mail.txt', user=user, token=token.token))
 	msg['Subject'] = 'Mail verification'
 	send_mail(newmail, msg)
 
@@ -153,10 +153,10 @@ def send_passwordreset(user, new=False):
 
 	msg = EmailMessage()
 	if new:
-		msg.set_content(render_template('newuser.mail.txt', user=user, token=token.token))
+		msg.set_content(render_template('selfservice/newuser.mail.txt', user=user, token=token.token))
 		msg['Subject'] = 'Welcome to the CCCV infrastructure'
 	else:
-		msg.set_content(render_template('passwordreset.mail.txt', user=user, token=token.token))
+		msg.set_content(render_template('selfservice/passwordreset.mail.txt', user=user, token=token.token))
 		msg['Subject'] = 'Password reset'
 	send_mail(user.mail, msg)
 
diff --git a/uffd/services/templates/overview.html b/uffd/services/templates/services/overview.html
similarity index 100%
rename from uffd/services/templates/overview.html
rename to uffd/services/templates/services/overview.html
diff --git a/uffd/services/views.py b/uffd/services/views.py
index f4f9a805156966300b07748ac68f3dbd17a83d36..f55be57f8c9f96069eb07b5b2ded6279c03e17df 100644
--- a/uffd/services/views.py
+++ b/uffd/services/views.py
@@ -90,4 +90,4 @@ def index():
 	if not (current_app.config["SERVICES_BANNER_PUBLIC"] or user):
 		banner = None
 
-	return render_template('overview.html', user=user, services=services, banner=banner)
+	return render_template('services/overview.html', user=user, services=services, banner=banner)
diff --git a/uffd/session/templates/login.html b/uffd/session/templates/session/login.html
similarity index 100%
rename from uffd/session/templates/login.html
rename to uffd/session/templates/session/login.html
diff --git a/uffd/session/views.py b/uffd/session/views.py
index 02a70d4e5edcbb42dfd84d24b2d99ee31b586538..1b76519d7db4947d75c68ec578ce8c8bea362b3f 100644
--- a/uffd/session/views.py
+++ b/uffd/session/views.py
@@ -60,7 +60,7 @@ def set_session(user, password='', skip_mfa=False):
 @bp.route("/login", methods=('GET', 'POST'))
 def login():
 	if request.method == 'GET':
-		return render_template('login.html', ref=request.values.get('ref'))
+		return render_template('session/login.html', ref=request.values.get('ref'))
 
 	username = request.form['loginname']
 	password = request.form['password']
@@ -71,16 +71,16 @@ def login():
 			flash('We received too many invalid login attempts for this user! Please wait at least %s.'%format_delay(login_delay))
 		else:
 			flash('We received too many requests from your ip address/network! Please wait at least %s.'%format_delay(host_delay))
-		return render_template('login.html', ref=request.values.get('ref'))
+		return render_template('session/login.html', ref=request.values.get('ref'))
 	user = login_get_user(username, password)
 	if user is None:
 		login_ratelimit.log(username)
 		host_ratelimit.log()
 		flash('Login name or password is wrong')
-		return render_template('login.html', ref=request.values.get('ref'))
+		return render_template('session/login.html', ref=request.values.get('ref'))
 	if not user.is_in_group(current_app.config['ACL_SELFSERVICE_GROUP']):
 		flash('You do not have access to this service')
-		return render_template('login.html', ref=request.values.get('ref'))
+		return render_template('session/login.html', ref=request.values.get('ref'))
 	set_session(user, password=password)
 	return redirect(url_for('mfa.auth', ref=request.values.get('ref', url_for('index'))))
 
diff --git a/uffd/user/templates/group_list.html b/uffd/user/templates/group/list.html
similarity index 100%
rename from uffd/user/templates/group_list.html
rename to uffd/user/templates/group/list.html
diff --git a/uffd/user/templates/group.html b/uffd/user/templates/group/show.html
similarity index 100%
rename from uffd/user/templates/group.html
rename to uffd/user/templates/group/show.html
diff --git a/uffd/user/templates/user_list.html b/uffd/user/templates/user/list.html
similarity index 100%
rename from uffd/user/templates/user_list.html
rename to uffd/user/templates/user/list.html
diff --git a/uffd/user/templates/user.html b/uffd/user/templates/user/show.html
similarity index 100%
rename from uffd/user/templates/user.html
rename to uffd/user/templates/user/show.html
diff --git a/uffd/user/views_group.py b/uffd/user/views_group.py
index 22fdd9e9d9def67a36fb5c18e2351878e61ebc0f..dca984e7f4b19caa451aa1a2af03bb4afd620f99 100644
--- a/uffd/user/views_group.py
+++ b/uffd/user/views_group.py
@@ -19,8 +19,8 @@ def group_acl_check():
 @bp.route("/")
 @register_navbar('Groups', icon='layer-group', blueprint=bp, visible=group_acl_check)
 def index():
-	return render_template('group_list.html', groups=Group.query.all())
+	return render_template('group/list.html', groups=Group.query.all())
 
 @bp.route("/<int:gid>")
 def show(gid):
-	return render_template('group.html', group=Group.query.filter_by(gid=gid).first_or_404())
+	return render_template('group/show.html', group=Group.query.filter_by(gid=gid).first_or_404())
diff --git a/uffd/user/views_user.py b/uffd/user/views_user.py
index 71f75d8efe645fd675bd8fa1d6f7913887823eae..aadaf5af9c3157f865880fa58980fd5f9e93b19a 100644
--- a/uffd/user/views_user.py
+++ b/uffd/user/views_user.py
@@ -27,13 +27,13 @@ def user_acl_check():
 @bp.route("/")
 @register_navbar('Users', icon='users', blueprint=bp, visible=user_acl_check)
 def index():
-	return render_template('user_list.html', users=User.query.all())
+	return render_template('user/list.html', users=User.query.all())
 
 @bp.route("/<int:uid>")
 @bp.route("/new")
 def show(uid=None):
 	user = User() if uid is None else User.query.filter_by(uid=uid).first_or_404()
-	return render_template('user.html', user=user, roles=Role.query.all())
+	return render_template('user/show.html', user=user, roles=Role.query.all())
 
 @bp.route("/<int:uid>/update", methods=['POST'])
 @bp.route("/new", methods=['POST'])