diff --git a/Gemfile b/Gemfile
index a7d5f9485..1b3b30478 100644
--- a/Gemfile
+++ b/Gemfile
@@ -34,6 +34,7 @@ gem "haml", "~> 6.1"
 gem "hcaptcha", "~> 7.0"
 gem "mini_magick"
 gem "oj"
+gem "panko_serializer"
 gem "rpush"
 gem "rqrcode"
 
diff --git a/Gemfile.lock b/Gemfile.lock
index 60e1d5a2c..e29e5f6f3 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -272,6 +272,9 @@ GEM
     oj (3.15.0)
     openssl (3.1.0)
     orm_adapter (0.5.0)
+    panko_serializer (0.8.0)
+      activesupport
+      oj (> 3.11.0, < 4.0.0)
     parallel (1.23.0)
     parser (3.2.2.3)
       ast (~> 2.4.1)
@@ -523,6 +526,7 @@ DEPENDENCIES
   net-smtp
   oj
   openssl (~> 3.1)
+  panko_serializer
   pg
   pghero
   prometheus-client (~> 4.1)
diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb
index 7b9084b99..072a84324 100644
--- a/app/controllers/user_controller.rb
+++ b/app/controllers/user_controller.rb
@@ -8,12 +8,15 @@ class UserController < ApplicationController
   after_action :mark_notification_as_read, only: %i[show]
 
   def show
-    @pinned_answers = @user.answers.pinned.order(pinned_at: :desc).limit(10)
-    paginate_answers { |args| @user.cursored_answers(**args) }
+    unless request.format == Mime[:json]
+      @pinned_answers = @user.answers.pinned.order(pinned_at: :desc).limit(10)
+      paginate_answers { |args| @user.cursored_answers(**args) }
+    end
 
     respond_to do |format|
       format.html
       format.turbo_stream { render layout: false }
+      format.json { render json: UserSerializer.new(context: { controller: self }).serialize_to_json(@user) }
     end
   end
 
diff --git a/app/serializers/image_serializer.rb b/app/serializers/image_serializer.rb
new file mode 100644
index 000000000..f6fbaa80f
--- /dev/null
+++ b/app/serializers/image_serializer.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class ImageSerializer < Panko::Serializer
+  attributes :type, :url, :mime
+  aliases mime: "mediaType"
+
+  def type = "Image"
+  def mime = object.content_type
+  def url = object.url
+end
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
new file mode 100644
index 000000000..5fcd97d35
--- /dev/null
+++ b/app/serializers/user_serializer.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class UserSerializer < Panko::Serializer
+  attributes :id, :type, :name, :url, :summary
+  aliases document_context: :@context
+  aliases created_at: :published
+  aliases screen_name: "preferredUsername"
+
+  has_one :profile_picture, serializer: ImageSerializer, name: :icon
+  has_one :profile_header, serializer: ImageSerializer, name: :image
+
+  def document_context = %w[https://www.w3.org/ns/activitystreams]
+
+  def id = context[:controller].activitypub_user_url(object)
+  def url = context[:controller].user_url(object)
+  def type = "Person"
+  def name = object.profile.display_name
+  def summary = object.profile.description
+end
diff --git a/config/routes.rb b/config/routes.rb
index 3af4b3a87..ffce31fc9 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -171,6 +171,8 @@
   get "/feedback/bugs(/*any)", to: "feedback#bugs", as: "feedback_bugs"
   get "/feedback/feature_requests(/*any)", to: "feedback#features", as: "feedback_features"
 
+  get "/users/:username", to: "user#show", as: "activitypub_user", defaults: { format: :json }
+
   namespace :well_known, path: "/.well-known" do
     get "/change-password", to: redirect("/settings/account")
     get "/nodeinfo", to: "node_info#discovery"