diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index 16f21d3465c0a683e5885063e07b8544e6712da9..e6eeff31dd1a5c59f149f5c3a9c20d391f50929f 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -31,6 +31,7 @@ subsonic_router.register(r"subsonic/rest", SubsonicViewSet, base_name="subsonic"
 
 
 v1_patterns += [
+    url(r"^oembed/$", views.OembedView.as_view(), name="oembed"),
     url(
         r"^instance/",
         include(("funkwhale_api.instance.urls", "instance"), namespace="instance"),
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 6a9501c0a298cad81481d9a5e773481f5344eb55..16efef696211f4fecddd19f5cfd309548fd6c9b0 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -70,7 +70,16 @@ else:
         FUNKWHALE_PROTOCOL = _parsed.scheme
 
 FUNKWHALE_URL = "{}://{}".format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME)
-
+FUNKWHALE_SPA_HTML_ROOT = env(
+    "FUNKWHALE_SPA_HTML_ROOT", default=FUNKWHALE_URL + "/front/"
+)
+FUNKWHALE_SPA_HTML_CACHE_DURATION = env.int(
+    "FUNKWHALE_SPA_HTML_CACHE_DURATION", default=60 * 15
+)
+FUNKWHALE_EMBED_URL = env(
+    "FUNKWHALE_EMBED_URL", default=FUNKWHALE_SPA_HTML_ROOT + "embed.html"
+)
+APP_NAME = "Funkwhale"
 
 # XXX: deprecated, see #186
 FEDERATION_ENABLED = env.bool("FEDERATION_ENABLED", default=True)
@@ -159,7 +168,7 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
 # MIDDLEWARE CONFIGURATION
 # ------------------------------------------------------------------------------
 MIDDLEWARE = (
-    # Make sure djangosecure.middleware.SecurityMiddleware is listed first
+    "funkwhale_api.common.middleware.SPAFallbackMiddleware",
     "django.contrib.sessions.middleware.SessionMiddleware",
     "corsheaders.middleware.CorsMiddleware",
     "django.middleware.common.CommonMiddleware",
@@ -305,6 +314,7 @@ FILE_UPLOAD_PERMISSIONS = 0o644
 # URL Configuration
 # ------------------------------------------------------------------------------
 ROOT_URLCONF = "config.urls"
+SPA_URLCONF = "config.spa_urls"
 # See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
 WSGI_APPLICATION = "config.wsgi.application"
 ASGI_APPLICATION = "config.routing.application"
@@ -400,7 +410,13 @@ if AUTH_LDAP_ENABLED:
 AUTOSLUG_SLUGIFY_FUNCTION = "slugify.slugify"
 
 CACHE_DEFAULT = "redis://127.0.0.1:6379/0"
-CACHES = {"default": env.cache_url("CACHE_URL", default=CACHE_DEFAULT)}
+CACHES = {
+    "default": env.cache_url("CACHE_URL", default=CACHE_DEFAULT),
+    "local": {
+        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
+        "LOCATION": "local-cache",
+    },
+}
 
 CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"
 
diff --git a/api/config/spa_urls.py b/api/config/spa_urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..071965b048b30ffb9aaf15c80d63cc5249389d10
--- /dev/null
+++ b/api/config/spa_urls.py
@@ -0,0 +1,18 @@
+from django import urls
+
+from funkwhale_api.music import spa_views
+
+
+urlpatterns = [
+    urls.re_path(
+        r"^library/tracks/(?P<pk>\d+)/?$", spa_views.library_track, name="library_track"
+    ),
+    urls.re_path(
+        r"^library/albums/(?P<pk>\d+)/?$", spa_views.library_album, name="library_album"
+    ),
+    urls.re_path(
+        r"^library/artists/(?P<pk>\d+)/?$",
+        spa_views.library_artist,
+        name="library_artist",
+    ),
+]
diff --git a/api/funkwhale_api/common/middleware.py b/api/funkwhale_api/common/middleware.py
new file mode 100644
index 0000000000000000000000000000000000000000..090d67c9e5c7a6370379649d1a0c3d4719d9c493
--- /dev/null
+++ b/api/funkwhale_api/common/middleware.py
@@ -0,0 +1,137 @@
+import html
+import requests
+
+from django import http
+from django.conf import settings
+from django.core.cache import caches
+from django import urls
+
+from . import preferences
+from . import utils
+
+EXCLUDED_PATHS = ["/api", "/federation", "/.well-known"]
+
+
+def should_fallback_to_spa(path):
+    if path == "/":
+        return True
+    return not any([path.startswith(m) for m in EXCLUDED_PATHS])
+
+
+def serve_spa(request):
+    html = get_spa_html(settings.FUNKWHALE_SPA_HTML_ROOT)
+    head, tail = html.split("</head>", 1)
+    if not preferences.get("common__api_authentication_required"):
+        try:
+            request_tags = get_request_head_tags(request) or []
+        except urls.exceptions.Resolver404:
+            # we don't have any custom tags for this route
+            request_tags = []
+    else:
+        # API is not open, we don't expose any custom data
+        request_tags = []
+    default_tags = get_default_head_tags(request.path)
+    unique_attributes = ["name", "property"]
+
+    final_tags = request_tags
+    skip = []
+
+    for t in final_tags:
+        for attr in unique_attributes:
+            if attr in t:
+                skip.append(t[attr])
+    for t in default_tags:
+        existing = False
+        for attr in unique_attributes:
+            if t.get(attr) in skip:
+                existing = True
+                break
+        if not existing:
+            final_tags.append(t)
+
+    # let's inject our meta tags in the HTML
+    head += "\n" + "\n".join(render_tags(final_tags)) + "\n</head>"
+
+    return http.HttpResponse(head + tail)
+
+
+def get_spa_html(spa_url):
+    cache_key = "spa-html:{}".format(spa_url)
+    cached = caches["local"].get(cache_key)
+    if cached:
+        return cached
+
+    response = requests.get(
+        utils.join_url(spa_url, "index.html"),
+        verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
+    )
+    response.raise_for_status()
+    content = response.text
+    caches["local"].set(cache_key, content, settings.FUNKWHALE_SPA_HTML_CACHE_DURATION)
+    return content
+
+
+def get_default_head_tags(path):
+    instance_name = preferences.get("instance__name")
+    short_description = preferences.get("instance__short_description")
+    app_name = settings.APP_NAME
+
+    parts = [instance_name, app_name]
+
+    return [
+        {"tag": "meta", "property": "og:type", "content": "website"},
+        {
+            "tag": "meta",
+            "property": "og:site_name",
+            "content": " - ".join([p for p in parts if p]),
+        },
+        {"tag": "meta", "property": "og:description", "content": short_description},
+        {
+            "tag": "meta",
+            "property": "og:image",
+            "content": utils.join_url(settings.FUNKWHALE_URL, "/front/favicon.png"),
+        },
+        {
+            "tag": "meta",
+            "property": "og:url",
+            "content": utils.join_url(settings.FUNKWHALE_URL, path),
+        },
+    ]
+
+
+def render_tags(tags):
+    """
+    Given a dict like {'tag': 'meta', 'hello': 'world'}
+    return a html ready tag like
+    <meta hello="world" />
+    """
+    for tag in tags:
+
+        yield "<{tag} {attrs} />".format(
+            tag=tag.pop("tag"),
+            attrs=" ".join(
+                [
+                    '{}="{}"'.format(a, html.escape(str(v)))
+                    for a, v in sorted(tag.items())
+                    if v
+                ]
+            ),
+        )
+
+
+def get_request_head_tags(request):
+    match = urls.resolve(request.path, urlconf=settings.SPA_URLCONF)
+    return match.func(request, *match.args, **match.kwargs)
+
+
+class SPAFallbackMiddleware:
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        response = self.get_response(request)
+
+        if response.status_code == 404 and should_fallback_to_spa(request.path):
+            return serve_spa(request)
+
+        return response
diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py
index deda2f5900e721db633fc52ea70b8da87bf5d6bb..81bc5c02694b934469cb9d665d5d01cb978cd520 100644
--- a/api/funkwhale_api/common/utils.py
+++ b/api/funkwhale_api/common/utils.py
@@ -3,9 +3,12 @@ from django.utils.deconstruct import deconstructible
 import os
 import shutil
 import uuid
+import xml.etree.ElementTree as ET
 
 from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
 
+from django.conf import settings
+from django import urls
 from django.db import transaction
 
 
@@ -107,3 +110,32 @@ def chunk_queryset(source_qs, chunk_size):
 
         if nb_items < chunk_size:
             return
+
+
+def join_url(start, end):
+    if start.endswith("/") and end.startswith("/"):
+        return start + end[1:]
+
+    if not start.endswith("/") and not end.startswith("/"):
+        return start + "/" + end
+
+    return start + end
+
+
+def spa_reverse(name, args=[], kwargs={}):
+    return urls.reverse(name, urlconf=settings.SPA_URLCONF, args=args, kwargs=kwargs)
+
+
+def spa_resolve(path):
+    return urls.resolve(path, urlconf=settings.SPA_URLCONF)
+
+
+def parse_meta(html):
+    # dirty but this is only for testing so we don't really care,
+    # we convert the html string to xml so it can be parsed as xml
+    html = '<?xml version="1.0"?>' + html
+    tree = ET.fromstring(html)
+
+    meta = [elem for elem in tree.iter() if elem.tag in ["meta", "link"]]
+
+    return [dict([("tag", elem.tag)] + list(elem.items())) for elem in meta]
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index 6157c5a75d7fb55328762dbefb8446a022b6d997..42e05765426af64d89787d75af4130b14c0d217e 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -1,12 +1,18 @@
+import urllib.parse
+
 from django.db import transaction
+from django import urls
+from django.conf import settings
 from rest_framework import serializers
 from taggit.models import Tag
 from versatileimagefield.serializers import VersatileImageFieldSerializer
 
 from funkwhale_api.activity import serializers as activity_serializers
 from funkwhale_api.common import serializers as common_serializers
+from funkwhale_api.common import preferences
 from funkwhale_api.common import utils as common_utils
 from funkwhale_api.federation import routes
+from funkwhale_api.federation import utils as federation_utils
 
 from . import filters, models, tasks
 
@@ -380,3 +386,98 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
 
     def get_type(self, obj):
         return "Audio"
+
+
+class OembedSerializer(serializers.Serializer):
+    format = serializers.ChoiceField(choices=["json"])
+    url = serializers.URLField()
+    maxheight = serializers.IntegerField(required=False)
+    maxwidth = serializers.IntegerField(required=False)
+
+    def validate(self, validated_data):
+        try:
+            match = common_utils.spa_resolve(
+                urllib.parse.urlparse(validated_data["url"]).path
+            )
+        except urls.exceptions.Resolver404:
+            raise serializers.ValidationError(
+                "Invalid URL {}".format(validated_data["url"])
+            )
+        data = {
+            "version": 1.0,
+            "type": "rich",
+            "provider_name": "{} - {}".format(
+                preferences.get("instance__name"), settings.APP_NAME
+            ),
+            "provider_url": settings.FUNKWHALE_URL,
+            "height": validated_data.get("maxheight") or 400,
+            "width": validated_data.get("maxwidth") or 600,
+        }
+        embed_id = None
+        embed_type = None
+        if match.url_name == "library_track":
+            qs = models.Track.objects.select_related("artist", "album__artist").filter(
+                pk=int(match.kwargs["pk"])
+            )
+            try:
+                track = qs.get()
+            except models.Track.DoesNotExist:
+                raise serializers.ValidationError(
+                    "No track matching id {}".format(match.kwargs["pk"])
+                )
+            embed_type = "track"
+            embed_id = track.pk
+            data["title"] = "{} by {}".format(track.title, track.artist.name)
+            if track.album.cover:
+                data["thumbnail_url"] = federation_utils.full_url(
+                    track.album.cover.crop["400x400"].url
+                )
+            data["description"] = track.full_name
+            data["author_name"] = track.artist.name
+            data["height"] = 150
+            data["author_url"] = federation_utils.full_url(
+                common_utils.spa_reverse(
+                    "library_artist", kwargs={"pk": track.artist.pk}
+                )
+            )
+        elif match.url_name == "library_album":
+            qs = models.Album.objects.select_related("artist").filter(
+                pk=int(match.kwargs["pk"])
+            )
+            try:
+                album = qs.get()
+            except models.Album.DoesNotExist:
+                raise serializers.ValidationError(
+                    "No album matching id {}".format(match.kwargs["pk"])
+                )
+            embed_type = "album"
+            embed_id = album.pk
+            if album.cover:
+                data["thumbnail_url"] = federation_utils.full_url(
+                    album.cover.crop["400x400"].url
+                )
+            data["title"] = "{} by {}".format(album.title, album.artist.name)
+            data["description"] = "{} by {}".format(album.title, album.artist.name)
+            data["author_name"] = album.artist.name
+            data["height"] = 400
+            data["author_url"] = federation_utils.full_url(
+                common_utils.spa_reverse(
+                    "library_artist", kwargs={"pk": album.artist.pk}
+                )
+            )
+        else:
+            raise serializers.ValidationError(
+                "Unsupported url: {}".format(validated_data["url"])
+            )
+        data[
+            "html"
+        ] = '<iframe width="{}" height="{}" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
+            data["width"],
+            data["height"],
+            settings.FUNKWHALE_EMBED_URL
+            + "?type={}&id={}".format(embed_type, embed_id),
+        )
+        return data
+
+    def create(self, data):
+        return data
diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..c1476e8a93c70d94134e02c93a9824677885f2d8
--- /dev/null
+++ b/api/funkwhale_api/music/spa_views.py
@@ -0,0 +1,161 @@
+from django.conf import settings
+from django.urls import reverse
+
+from funkwhale_api.common import utils
+
+from . import models
+
+
+def library_track(request, pk):
+    queryset = models.Track.objects.filter(pk=pk).select_related("album", "artist")
+    try:
+        obj = queryset.get()
+    except models.Track.DoesNotExist:
+        return []
+    track_url = utils.join_url(
+        settings.FUNKWHALE_URL,
+        utils.spa_reverse("library_track", kwargs={"pk": obj.pk}),
+    )
+    metas = [
+        {"tag": "meta", "property": "og:url", "content": track_url},
+        {"tag": "meta", "property": "og:title", "content": obj.title},
+        {"tag": "meta", "property": "og:type", "content": "music.song"},
+        {"tag": "meta", "property": "music:album:disc", "content": obj.disc_number},
+        {"tag": "meta", "property": "music:album:track", "content": obj.position},
+        {
+            "tag": "meta",
+            "property": "music:musician",
+            "content": utils.join_url(
+                settings.FUNKWHALE_URL,
+                utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.pk}),
+            ),
+        },
+        {
+            "tag": "meta",
+            "property": "music:album",
+            "content": utils.join_url(
+                settings.FUNKWHALE_URL,
+                utils.spa_reverse("library_album", kwargs={"pk": obj.album.pk}),
+            ),
+        },
+    ]
+    if obj.album.cover:
+        metas.append(
+            {
+                "tag": "meta",
+                "property": "og:image",
+                "content": utils.join_url(settings.FUNKWHALE_URL, obj.album.cover.url),
+            }
+        )
+
+    if obj.uploads.playable_by(None).exists():
+        metas.append(
+            {
+                "tag": "meta",
+                "property": "og:audio",
+                "content": utils.join_url(settings.FUNKWHALE_URL, obj.listen_url),
+            }
+        )
+        metas.append(
+            {
+                "tag": "link",
+                "rel": "alternate",
+                "type": "application/json+oembed",
+                "href": (
+                    utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
+                    + "?url={}".format(track_url)
+                ),
+            }
+        )
+    return metas
+
+
+def library_album(request, pk):
+    queryset = models.Album.objects.filter(pk=pk).select_related("artist")
+    try:
+        obj = queryset.get()
+    except models.Album.DoesNotExist:
+        return []
+    album_url = utils.join_url(
+        settings.FUNKWHALE_URL,
+        utils.spa_reverse("library_album", kwargs={"pk": obj.pk}),
+    )
+    metas = [
+        {"tag": "meta", "property": "og:url", "content": album_url},
+        {"tag": "meta", "property": "og:title", "content": obj.title},
+        {"tag": "meta", "property": "og:type", "content": "music.album"},
+        {
+            "tag": "meta",
+            "property": "music:musician",
+            "content": utils.join_url(
+                settings.FUNKWHALE_URL,
+                utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.pk}),
+            ),
+        },
+    ]
+
+    if obj.release_date:
+        metas.append(
+            {
+                "tag": "meta",
+                "property": "music:release_date",
+                "content": str(obj.release_date),
+            }
+        )
+
+    if obj.cover:
+        metas.append(
+            {
+                "tag": "meta",
+                "property": "og:image",
+                "content": utils.join_url(settings.FUNKWHALE_URL, obj.cover.url),
+            }
+        )
+
+    if models.Upload.objects.filter(track__album=obj).playable_by(None).exists():
+        metas.append(
+            {
+                "tag": "link",
+                "rel": "alternate",
+                "type": "application/json+oembed",
+                "href": (
+                    utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
+                    + "?url={}".format(album_url)
+                ),
+            }
+        )
+    return metas
+
+
+def library_artist(request, pk):
+    queryset = models.Artist.objects.filter(pk=pk)
+    try:
+        obj = queryset.get()
+    except models.Artist.DoesNotExist:
+        return []
+    artist_url = utils.join_url(
+        settings.FUNKWHALE_URL,
+        utils.spa_reverse("library_artist", kwargs={"pk": obj.pk}),
+    )
+    # we use latest album's cover as artist image
+    latest_album = (
+        obj.albums.exclude(cover="").exclude(cover=None).order_by("release_date").last()
+    )
+    metas = [
+        {"tag": "meta", "property": "og:url", "content": artist_url},
+        {"tag": "meta", "property": "og:title", "content": obj.name},
+        {"tag": "meta", "property": "og:type", "content": "profile"},
+    ]
+
+    if latest_album and latest_album.cover:
+        metas.append(
+            {
+                "tag": "meta",
+                "property": "og:image",
+                "content": utils.join_url(
+                    settings.FUNKWHALE_URL, latest_album.cover.url
+                ),
+            }
+        )
+
+    return metas
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index 19a00884bbd32de166756fd9aa7760aa1baa8bdb..1fcd782fbb8602f882ec67fc3ffc2d3334b17df8 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -508,3 +508,13 @@ class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
         except AttributeError:
             first_arg = [i.conf for i in instance_or_qs if i.conf]
         return super().get_serializer(*((first_arg,) + args[1:]), **kwargs)
+
+
+class OembedView(views.APIView):
+    permission_classes = [common_permissions.ConditionalAuthentication]
+
+    def get(self, request, *args, **kwargs):
+        serializer = serializers.OembedSerializer(data=request.GET)
+        serializer.is_valid(raise_exception=True)
+        embed_data = serializer.save()
+        return Response(embed_data)
diff --git a/api/tests/common/test_middleware.py b/api/tests/common/test_middleware.py
new file mode 100644
index 0000000000000000000000000000000000000000..2ed875a53100fb216d09ade2af6fceea3bee4ca1
--- /dev/null
+++ b/api/tests/common/test_middleware.py
@@ -0,0 +1,137 @@
+import pytest
+
+from funkwhale_api.common import middleware
+
+
+def test_spa_fallback_middleware_no_404(mocker):
+    get_response = mocker.Mock()
+    get_response.return_value = mocker.Mock(status_code=200)
+    request = mocker.Mock(path="/")
+    m = middleware.SPAFallbackMiddleware(get_response)
+
+    assert m(request) == get_response.return_value
+
+
+def test_spa_middleware_calls_should_fallback_false(mocker):
+    get_response = mocker.Mock()
+    get_response.return_value = mocker.Mock(status_code=404)
+    should_falback = mocker.patch.object(
+        middleware, "should_fallback_to_spa", return_value=False
+    )
+    request = mocker.Mock(path="/")
+
+    m = middleware.SPAFallbackMiddleware(get_response)
+
+    assert m(request) == get_response.return_value
+    should_falback.assert_called_once_with(request.path)
+
+
+def test_spa_middleware_should_fallback_true(mocker):
+    get_response = mocker.Mock()
+    get_response.return_value = mocker.Mock(status_code=404)
+    request = mocker.Mock(path="/")
+    mocker.patch.object(middleware, "should_fallback_to_spa", return_value=True)
+    serve_spa = mocker.patch.object(middleware, "serve_spa")
+    m = middleware.SPAFallbackMiddleware(get_response)
+
+    assert m(request) == serve_spa.return_value
+    serve_spa.assert_called_once_with(request)
+
+
+@pytest.mark.parametrize(
+    "path,expected",
+    [("/", True), ("/federation", False), ("/api", False), ("/an/spa/path/", True)],
+)
+def test_should_fallback(path, expected, mocker):
+    assert middleware.should_fallback_to_spa(path) is expected
+
+
+def test_serve_spa_from_cache(mocker, settings, preferences, no_api_auth):
+
+    request = mocker.Mock(path="/")
+    get_spa_html = mocker.patch.object(
+        middleware, "get_spa_html", return_value="<html><head></head></html>"
+    )
+    mocker.patch.object(
+        middleware,
+        "get_default_head_tags",
+        return_value=[
+            {"tag": "meta", "property": "og:title", "content": "default title"},
+            {"tag": "meta", "property": "og:site_name", "content": "default site name"},
+        ],
+    )
+    get_request_head_tags = mocker.patch.object(
+        middleware,
+        "get_request_head_tags",
+        return_value=[
+            {"tag": "meta", "property": "og:title", "content": "custom title"},
+            {
+                "tag": "meta",
+                "property": "og:description",
+                "content": "custom description",
+            },
+        ],
+    )
+    response = middleware.serve_spa(request)
+
+    assert response.status_code == 200
+    expected = [
+        "<html><head>",
+        '<meta content="custom title" property="og:title" />',
+        '<meta content="custom description" property="og:description" />',
+        '<meta content="default site name" property="og:site_name" />',
+        "</head></html>",
+    ]
+    get_spa_html.assert_called_once_with(settings.FUNKWHALE_SPA_HTML_ROOT)
+    get_request_head_tags.assert_called_once_with(request)
+    assert response.content == "\n".join(expected).encode()
+
+
+def test_get_default_head_tags(preferences, settings):
+    settings.APP_NAME = "Funkwhale"
+    preferences["instance__name"] = "Hello"
+    preferences["instance__short_description"] = "World"
+
+    expected = [
+        {"tag": "meta", "property": "og:type", "content": "website"},
+        {"tag": "meta", "property": "og:site_name", "content": "Hello - Funkwhale"},
+        {"tag": "meta", "property": "og:description", "content": "World"},
+        {
+            "tag": "meta",
+            "property": "og:image",
+            "content": settings.FUNKWHALE_URL + "/front/favicon.png",
+        },
+        {"tag": "meta", "property": "og:url", "content": settings.FUNKWHALE_URL + "/"},
+    ]
+
+    assert middleware.get_default_head_tags("/") == expected
+
+
+def test_get_spa_html_from_cache(local_cache):
+    local_cache.set("spa-html:http://test", "hello world")
+
+    assert middleware.get_spa_html("http://test") == "hello world"
+
+
+def test_get_spa_html_from_http(local_cache, r_mock, mocker, settings):
+    cache_set = mocker.spy(local_cache, "set")
+    url = "http://test"
+    r_mock.get(url + "/index.html", text="hello world")
+
+    assert middleware.get_spa_html("http://test") == "hello world"
+    cache_set.assert_called_once_with(
+        "spa-html:{}".format(url),
+        "hello world",
+        settings.FUNKWHALE_SPA_HTML_CACHE_DURATION,
+    )
+
+
+def test_get_route_head_tags(mocker, settings):
+    match = mocker.Mock(args=[], kwargs={"pk": 42}, func=mocker.Mock())
+    resolve = mocker.patch("django.urls.resolve", return_value=match)
+    request = mocker.Mock(path="/tracks/42")
+    tags = middleware.get_request_head_tags(request)
+
+    assert tags == match.func.return_value
+    match.func.assert_called_once_with(request, *[], **{"pk": 42})
+    resolve.assert_called_once_with(request.path, urlconf=settings.SPA_URLCONF)
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index d880c8d6faa96c7f007f81721f607fe439040dbd..99317303c86aa8f9980e7dda2196fcf3e48906f8 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -13,7 +13,7 @@ import factory
 import pytest
 
 from django.contrib.auth.models import AnonymousUser
-from django.core.cache import cache as django_cache
+from django.core.cache import cache as django_cache, caches
 from django.core.files import uploadedfile
 from django.utils import timezone
 from django.test import client
@@ -100,6 +100,12 @@ def cache():
     django_cache.clear()
 
 
+@pytest.fixture(autouse=True)
+def local_cache():
+    yield caches["local"]
+    caches["local"].clear()
+
+
 @pytest.fixture
 def factories(db):
     """
@@ -382,3 +388,15 @@ def temp_signal(mocker):
 @pytest.fixture()
 def stdout():
     yield io.StringIO()
+
+
+@pytest.fixture
+def spa_html(r_mock, settings):
+    yield r_mock.get(
+        settings.FUNKWHALE_SPA_HTML_ROOT + "index.html", text="<head></head>"
+    )
+
+
+@pytest.fixture
+def no_api_auth(preferences):
+    preferences["common__api_authentication_required"] = False
diff --git a/api/tests/music/test_spa_views.py b/api/tests/music/test_spa_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..a60da50dffb3e9bb7f023e8212a1b3c045dd92bc
--- /dev/null
+++ b/api/tests/music/test_spa_views.py
@@ -0,0 +1,148 @@
+from django.urls import reverse
+
+from funkwhale_api.common import utils
+
+
+def test_library_track(spa_html, no_api_auth, client, factories, settings):
+    track = factories["music.Upload"](playable=True, track__disc_number=1).track
+    url = "/library/tracks/{}".format(track.pk)
+
+    response = client.get(url)
+
+    expected_metas = [
+        {
+            "tag": "meta",
+            "property": "og:url",
+            "content": utils.join_url(settings.FUNKWHALE_URL, url),
+        },
+        {"tag": "meta", "property": "og:title", "content": track.title},
+        {"tag": "meta", "property": "og:type", "content": "music.song"},
+        {
+            "tag": "meta",
+            "property": "music:album:disc",
+            "content": str(track.disc_number),
+        },
+        {
+            "tag": "meta",
+            "property": "music:album:track",
+            "content": str(track.position),
+        },
+        {
+            "tag": "meta",
+            "property": "music:musician",
+            "content": utils.join_url(
+                settings.FUNKWHALE_URL,
+                utils.spa_reverse("library_artist", kwargs={"pk": track.artist.pk}),
+            ),
+        },
+        {
+            "tag": "meta",
+            "property": "music:album",
+            "content": utils.join_url(
+                settings.FUNKWHALE_URL,
+                utils.spa_reverse("library_album", kwargs={"pk": track.album.pk}),
+            ),
+        },
+        {
+            "tag": "meta",
+            "property": "og:image",
+            "content": utils.join_url(settings.FUNKWHALE_URL, track.album.cover.url),
+        },
+        {
+            "tag": "meta",
+            "property": "og:audio",
+            "content": utils.join_url(settings.FUNKWHALE_URL, track.listen_url),
+        },
+        {
+            "tag": "link",
+            "rel": "alternate",
+            "type": "application/json+oembed",
+            "href": (
+                utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
+                + "?url={}".format(utils.join_url(settings.FUNKWHALE_URL, url))
+            ),
+        },
+    ]
+
+    metas = utils.parse_meta(response.content.decode())
+
+    # we only test our custom metas, not the default ones
+    assert metas[: len(expected_metas)] == expected_metas
+
+
+def test_library_album(spa_html, no_api_auth, client, factories, settings):
+    track = factories["music.Upload"](playable=True, track__disc_number=1).track
+    album = track.album
+    url = "/library/albums/{}".format(album.pk)
+
+    response = client.get(url)
+
+    expected_metas = [
+        {
+            "tag": "meta",
+            "property": "og:url",
+            "content": utils.join_url(settings.FUNKWHALE_URL, url),
+        },
+        {"tag": "meta", "property": "og:title", "content": album.title},
+        {"tag": "meta", "property": "og:type", "content": "music.album"},
+        {
+            "tag": "meta",
+            "property": "music:musician",
+            "content": utils.join_url(
+                settings.FUNKWHALE_URL,
+                utils.spa_reverse("library_artist", kwargs={"pk": album.artist.pk}),
+            ),
+        },
+        {
+            "tag": "meta",
+            "property": "music:release_date",
+            "content": str(album.release_date),
+        },
+        {
+            "tag": "meta",
+            "property": "og:image",
+            "content": utils.join_url(settings.FUNKWHALE_URL, album.cover.url),
+        },
+        {
+            "tag": "link",
+            "rel": "alternate",
+            "type": "application/json+oembed",
+            "href": (
+                utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
+                + "?url={}".format(utils.join_url(settings.FUNKWHALE_URL, url))
+            ),
+        },
+    ]
+
+    metas = utils.parse_meta(response.content.decode())
+
+    # we only test our custom metas, not the default ones
+    assert metas[: len(expected_metas)] == expected_metas
+
+
+def test_library_artist(spa_html, no_api_auth, client, factories, settings):
+    album = factories["music.Album"]()
+    artist = album.artist
+    url = "/library/artists/{}".format(artist.pk)
+
+    response = client.get(url)
+
+    expected_metas = [
+        {
+            "tag": "meta",
+            "property": "og:url",
+            "content": utils.join_url(settings.FUNKWHALE_URL, url),
+        },
+        {"tag": "meta", "property": "og:title", "content": artist.name},
+        {"tag": "meta", "property": "og:type", "content": "profile"},
+        {
+            "tag": "meta",
+            "property": "og:image",
+            "content": utils.join_url(settings.FUNKWHALE_URL, album.cover.url),
+        },
+    ]
+
+    metas = utils.parse_meta(response.content.decode())
+
+    # we only test our custom metas, not the default ones
+    assert metas[: len(expected_metas)] == expected_metas
diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py
index 263b7878472dcd9f53ed94737e3f05ecd79ce7d2..d954787b0e529acd1019af55b00b30abc5fd7e66 100644
--- a/api/tests/music/test_views.py
+++ b/api/tests/music/test_views.py
@@ -6,8 +6,10 @@ import pytest
 from django.urls import reverse
 from django.utils import timezone
 
-from funkwhale_api.music import licenses, models, serializers, tasks, views
+from funkwhale_api.common import utils
 from funkwhale_api.federation import api_serializers as federation_api_serializers
+from funkwhale_api.federation import utils as federation_utils
+from funkwhale_api.music import licenses, models, serializers, tasks, views
 
 DATA_DIR = os.path.dirname(os.path.abspath(__file__))
 
@@ -570,3 +572,74 @@ def test_detail_license(api_client, preferences):
     response = api_client.get(url)
 
     assert response.data == expected
+
+
+def test_oembed_track(factories, no_api_auth, api_client, settings, preferences):
+    settings.FUNKWHALE_URL = "http://test"
+    settings.FUNKWHALE_EMBED_URL = "http://embed"
+    preferences["instance__name"] = "Hello"
+    track = factories["music.Track"]()
+    url = reverse("api:v1:oembed")
+    track_url = "https://test.com/library/tracks/{}".format(track.pk)
+    iframe_src = "http://embed?type=track&id={}".format(track.pk)
+    expected = {
+        "version": 1.0,
+        "type": "rich",
+        "provider_name": "{} - {}".format(
+            preferences["instance__name"], settings.APP_NAME
+        ),
+        "provider_url": settings.FUNKWHALE_URL,
+        "height": 150,
+        "width": 600,
+        "title": "{} by {}".format(track.title, track.artist.name),
+        "description": track.full_name,
+        "thumbnail_url": federation_utils.full_url(
+            track.album.cover.crop["400x400"].url
+        ),
+        "html": '<iframe width="600" height="150" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
+            iframe_src
+        ),
+        "author_name": track.artist.name,
+        "author_url": federation_utils.full_url(
+            utils.spa_reverse("library_artist", kwargs={"pk": track.artist.pk})
+        ),
+    }
+
+    response = api_client.get(url, {"url": track_url, "format": "json"})
+
+    assert response.data == expected
+
+
+def test_oembed_album(factories, no_api_auth, api_client, settings, preferences):
+    settings.FUNKWHALE_URL = "http://test"
+    settings.FUNKWHALE_EMBED_URL = "http://embed"
+    preferences["instance__name"] = "Hello"
+    track = factories["music.Track"]()
+    album = track.album
+    url = reverse("api:v1:oembed")
+    album_url = "https://test.com/library/albums/{}".format(album.pk)
+    iframe_src = "http://embed?type=album&id={}".format(album.pk)
+    expected = {
+        "version": 1.0,
+        "type": "rich",
+        "provider_name": "{} - {}".format(
+            preferences["instance__name"], settings.APP_NAME
+        ),
+        "provider_url": settings.FUNKWHALE_URL,
+        "height": 400,
+        "width": 600,
+        "title": "{} by {}".format(album.title, album.artist.name),
+        "description": "{} by {}".format(album.title, album.artist.name),
+        "thumbnail_url": federation_utils.full_url(album.cover.crop["400x400"].url),
+        "html": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
+            iframe_src
+        ),
+        "author_name": album.artist.name,
+        "author_url": federation_utils.full_url(
+            utils.spa_reverse("library_artist", kwargs={"pk": album.artist.pk})
+        ),
+    }
+
+    response = api_client.get(url, {"url": album_url, "format": "json"})
+
+    assert response.data == expected
diff --git a/deploy/docker.nginx.template b/deploy/docker.nginx.template
index d73a1c4b6ebd176aa0ef761affb80ba1c667a2d8..1e2ab0014b74d54e2e7e55db1255aee3c66e6860 100644
--- a/deploy/docker.nginx.template
+++ b/deploy/docker.nginx.template
@@ -24,17 +24,14 @@ server {
     root /frontend;
 
     location / {
-        try_files $uri $uri/ @rewrites;
-    }
-
-    location @rewrites {
-        rewrite ^(.+)$ /index.html last;
-    }
-    location /api/ {
         include /etc/nginx/funkwhale_proxy.conf;
         # this is needed if you have file import via upload enabled
         client_max_body_size ${NGINX_MAX_BODY_SIZE};
-        proxy_pass   http://funkwhale-api/api/;
+        proxy_pass   http://funkwhale-api/;
+    }
+
+    location /front/ {
+        alias /frontend;
     }
 
     location /federation/ {
diff --git a/deploy/nginx.template b/deploy/nginx.template
index 1eb011d4efc1ff11162863b1e69d21c847422d3a..f6bf610694946ccfa5193afe57acb723cc3452e2 100644
--- a/deploy/nginx.template
+++ b/deploy/nginx.template
@@ -44,17 +44,14 @@ server {
     root ${FUNKWHALE_FRONTEND_PATH};
 
     location / {
-        try_files $uri $uri/ @rewrites;
-    }
-
-    location @rewrites {
-        rewrite ^(.+)$ /index.html last;
-    }
-    location /api/ {
         include /etc/nginx/funkwhale_proxy.conf;
         # this is needed if you have file import via upload enabled
         client_max_body_size ${NGINX_MAX_BODY_SIZE};
-        proxy_pass   http://funkwhale-api/api/;
+        proxy_pass   http://funkwhale-api/;
+    }
+
+    location /front/ {
+        alias ${FUNKWHALE_FRONTEND_PATH};
     }
 
     location /federation/ {