diff --git a/internal/api/model/timeline.go b/internal/api/model/timeline.go
index 71d839ed..4c9c78a8 100644
--- a/internal/api/model/timeline.go
+++ b/internal/api/model/timeline.go
@@ -25,4 +25,6 @@ import "github.com/superseriousbusiness/gotosocial/internal/timeline"
 type TimelineResponse struct {
 	Items      []timeline.Timelineable
 	LinkHeader string
+	NextLink   string
+	PrevLink   string
 }
diff --git a/internal/db/account.go b/internal/db/account.go
index 4b0b0062..79e7c01a 100644
--- a/internal/db/account.go
+++ b/internal/db/account.go
@@ -54,6 +54,11 @@ type Account interface {
 	// In case of no entries, a 'no entries' error will be returned
 	GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, Error)
 
+	// GetAccountWebStatuses is similar to GetAccountStatuses, but it's specifically for returning statuses that
+	// should be visible via the web view of an account. So, only public, federated statuses that aren't boosts
+	// or replies.
+	GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, Error)
+
 	GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, Error)
 
 	// GetAccountLastPosted simply gets the timestamp of the most recent post by the account.
diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go
index cf02ad10..2c97a664 100644
--- a/internal/db/bundb/account.go
+++ b/internal/db/bundb/account.go
@@ -301,27 +301,33 @@ func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, li
 		return nil, a.conn.ProcessError(err)
 	}
 
-	// Catch case of no statuses early
-	if len(statusIDs) == 0 {
-		return nil, db.ErrNoEntries
+	return a.statusesFromIDs(ctx, statusIDs)
+}
+
+func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, db.Error) {
+	statusIDs := []string{}
+
+	q := a.conn.
+		NewSelect().
+		Table("statuses").
+		Column("id").
+		Where("account_id = ?", accountID).
+		WhereGroup(" AND ", whereEmptyOrNull("in_reply_to_id")).
+		WhereGroup(" AND ", whereEmptyOrNull("boost_of_id")).
+		Where("visibility = ?", gtsmodel.VisibilityPublic).
+		Where("federated = ?", true)
+
+	if maxID != "" {
+		q = q.Where("id < ?", maxID)
 	}
 
-	// Allocate return slice (will be at most len statusIDS)
-	statuses := make([]*gtsmodel.Status, 0, len(statusIDs))
+	q = q.Limit(limit).Order("id DESC")
 
-	for _, id := range statusIDs {
-		// Fetch from status from database by ID
-		status, err := a.status.GetStatusByID(ctx, id)
-		if err != nil {
-			logrus.Errorf("GetAccountStatuses: error getting status %q: %v", id, err)
-			continue
-		}
-
-		// Append to return slice
-		statuses = append(statuses, status)
+	if err := q.Scan(ctx, &statusIDs); err != nil {
+		return nil, a.conn.ProcessError(err)
 	}
 
-	return statuses, nil
+	return a.statusesFromIDs(ctx, statusIDs)
 }
 
 func (a *accountDB) GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, db.Error) {
@@ -363,3 +369,27 @@ func (a *accountDB) GetAccountBlocks(ctx context.Context, accountID string, maxI
 	prevMinID := blocks[0].ID
 	return accounts, nextMaxID, prevMinID, nil
 }
+
+func (a *accountDB) statusesFromIDs(ctx context.Context, statusIDs []string) ([]*gtsmodel.Status, db.Error) {
+	// Catch case of no statuses early
+	if len(statusIDs) == 0 {
+		return nil, db.ErrNoEntries
+	}
+
+	// Allocate return slice (will be at most len statusIDS)
+	statuses := make([]*gtsmodel.Status, 0, len(statusIDs))
+
+	for _, id := range statusIDs {
+		// Fetch from status from database by ID
+		status, err := a.status.GetStatusByID(ctx, id)
+		if err != nil {
+			logrus.Errorf("statusesFromIDs: error getting status %q: %v", id, err)
+			continue
+		}
+
+		// Append to return slice
+		statuses = append(statuses, status)
+	}
+
+	return statuses, nil
+}
diff --git a/internal/processing/account.go b/internal/processing/account.go
index 2c21aee2..df351d7b 100644
--- a/internal/processing/account.go
+++ b/internal/processing/account.go
@@ -50,6 +50,10 @@ func (p *processor) AccountStatusesGet(ctx context.Context, authed *oauth.Auth,
 	return p.accountProcessor.StatusesGet(ctx, authed.Account, targetAccountID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly)
 }
 
+func (p *processor) AccountWebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.TimelineResponse, gtserror.WithCode) {
+	return p.accountProcessor.WebStatusesGet(ctx, targetAccountID, maxID)
+}
+
 func (p *processor) AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) {
 	return p.accountProcessor.FollowersGet(ctx, authed.Account, targetAccountID)
 }
diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go
index 56dfb90e..868308ef 100644
--- a/internal/processing/account/account.go
+++ b/internal/processing/account/account.go
@@ -56,6 +56,9 @@ type Processor interface {
 	// StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for
 	// the account given in authed.
 	StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) (*apimodel.TimelineResponse, gtserror.WithCode)
+	// WebStatusesGet fetches a number of statuses (in descending order) from the given account. It selects only
+	// statuses which are suitable for showing on the public web profile of an account.
+	WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.TimelineResponse, gtserror.WithCode)
 	// FollowersGet fetches a list of the target account's followers.
 	FollowersGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode)
 	// FollowingGet fetches a list of the accounts that target account is following.
diff --git a/internal/processing/account/getstatuses.go b/internal/processing/account/getstatuses.go
index 90bf8e06..7dbe6846 100644
--- a/internal/processing/account/getstatuses.go
+++ b/internal/processing/account/getstatuses.go
@@ -84,3 +84,45 @@ func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel
 		},
 	})
 }
+
+func (p *processor) WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.TimelineResponse, gtserror.WithCode) {
+	acct, err := p.db.GetAccountByID(ctx, targetAccountID)
+	if err != nil {
+		if err == db.ErrNoEntries {
+			err := fmt.Errorf("account %s not found in the db, not getting web statuses for it", targetAccountID)
+			return nil, gtserror.NewErrorNotFound(err)
+		}
+		return nil, gtserror.NewErrorInternalError(err)
+	}
+
+	if acct.Domain != "" {
+		err := fmt.Errorf("account %s was not a local account, not getting web statuses for it", targetAccountID)
+		return nil, gtserror.NewErrorNotFound(err)
+	}
+
+	statuses, err := p.db.GetAccountWebStatuses(ctx, targetAccountID, 10, maxID)
+	if err != nil {
+		if err == db.ErrNoEntries {
+			return util.EmptyTimelineResponse(), nil
+		}
+		return nil, gtserror.NewErrorInternalError(err)
+	}
+
+	timelineables := []timeline.Timelineable{}
+	for _, i := range statuses {
+		apiStatus, err := p.tc.StatusToAPIStatus(ctx, i, nil)
+		if err != nil {
+			return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to api: %s", err))
+		}
+
+		timelineables = append(timelineables, apiStatus)
+	}
+
+	return util.PackageTimelineableResponse(util.TimelineableResponseParams{
+		Items:            timelineables,
+		Path:             "/@" + acct.Username,
+		NextMaxIDValue:   timelineables[len(timelineables)-1].GetID(),
+		PrevMinIDValue:   timelineables[0].GetID(),
+		ExtraQueryParams: []string{},
+	})
+}
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
index b8d0daf9..5a4abb55 100644
--- a/internal/processing/processor.go
+++ b/internal/processing/processor.go
@@ -84,6 +84,9 @@ type Processor interface {
 	// AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for
 	// the account given in authed.
 	AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) (*apimodel.TimelineResponse, gtserror.WithCode)
+	// AccountWebStatusesGet fetches a number of statuses (in descending order) from the given account. It selects only
+	// statuses which are suitable for showing on the public web profile of an account.
+	AccountWebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.TimelineResponse, gtserror.WithCode)
 	// AccountFollowersGet fetches a list of the target account's followers.
 	AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode)
 	// AccountFollowingGet fetches a list of the accounts that target account is following.
diff --git a/internal/util/timeline.go b/internal/util/timeline.go
index 929464ad..bdb0f3f0 100644
--- a/internal/util/timeline.go
+++ b/internal/util/timeline.go
@@ -61,12 +61,15 @@ func PackageTimelineableResponse(params TimelineableResponseParams) (*apimodel.T
 		Items: params.Items,
 	}
 
-	// prepare the next and previous links
 	if len(params.Items) != 0 {
 		protocol := config.GetProtocol()
 		host := config.GetHost()
 
-		nextRaw := fmt.Sprintf("limit=%d&%s=%s", params.Limit, params.NextMaxIDKey, params.NextMaxIDValue)
+		// next
+		nextRaw := params.NextMaxIDKey + "=" + params.NextMaxIDValue
+		if params.Limit != 0 {
+			nextRaw = fmt.Sprintf("limit=%d&", params.Limit) + nextRaw
+		}
 		for _, p := range params.ExtraQueryParams {
 			nextRaw = nextRaw + "&" + p
 		}
@@ -76,9 +79,14 @@ func PackageTimelineableResponse(params TimelineableResponseParams) (*apimodel.T
 			Path:     params.Path,
 			RawQuery: nextRaw,
 		}
-		next := fmt.Sprintf("<%s>; rel=\"next\"", nextLink.String())
+		nextLinkString := nextLink.String()
+		timelineResponse.NextLink = nextLinkString
 
-		prevRaw := fmt.Sprintf("limit=%d&%s=%s", params.Limit, params.PrevMinIDKey, params.PrevMinIDValue)
+		// prev
+		prevRaw := params.PrevMinIDKey + "=" + params.PrevMinIDValue
+		if params.Limit != 0 {
+			prevRaw = fmt.Sprintf("limit=%d&", params.Limit) + prevRaw
+		}
 		for _, p := range params.ExtraQueryParams {
 			prevRaw = prevRaw + "&" + p
 		}
@@ -88,7 +96,12 @@ func PackageTimelineableResponse(params TimelineableResponseParams) (*apimodel.T
 			Path:     params.Path,
 			RawQuery: prevRaw,
 		}
-		prev := fmt.Sprintf("<%s>; rel=\"prev\"", prevLink.String())
+		prevLinkString := prevLink.String()
+		timelineResponse.PrevLink = prevLinkString
+
+		// link header
+		next := fmt.Sprintf("<%s>; rel=\"next\"", nextLinkString)
+		prev := fmt.Sprintf("<%s>; rel=\"prev\"", prevLinkString)
 		timelineResponse.LinkHeader = next + ", " + prev
 	}
 
diff --git a/internal/web/profile.go b/internal/web/profile.go
index ce3fe645..542c015f 100644
--- a/internal/web/profile.go
+++ b/internal/web/profile.go
@@ -36,6 +36,11 @@ import (
 	"github.com/superseriousbusiness/gotosocial/internal/oauth"
 )
 
+const (
+	// MaxStatusIDKey is for specifying the maximum ID of the status to retrieve.
+	MaxStatusIDKey = "max_id"
+)
+
 func (m *Module) profileGETHandler(c *gin.Context) {
 	ctx := c.Request.Context()
 
@@ -78,10 +83,18 @@ func (m *Module) profileGETHandler(c *gin.Context) {
 		return
 	}
 
-	// get latest 10 top-level public statuses;
-	// ie., exclude replies and boosts, public only,
-	// with or without media
-	statusResp, errWithCode := m.processor.AccountStatusesGet(ctx, authed, account.ID, 10, true, true, "", "", false, false, true)
+	// we should only show the 'back to top' button if the
+	// profile visitor is paging through statuses
+	showBackToTop := false
+
+	maxStatusID := ""
+	maxStatusIDString := c.Query(MaxStatusIDKey)
+	if maxStatusIDString != "" {
+		maxStatusID = maxStatusIDString
+		showBackToTop = true
+	}
+
+	statusResp, errWithCode := m.processor.AccountWebStatusesGet(ctx, account.ID, maxStatusID)
 	if errWithCode != nil {
 		api.ErrorHandler(c, errWithCode, instanceGet)
 		return
@@ -103,9 +116,11 @@ func (m *Module) profileGETHandler(c *gin.Context) {
 	}
 
 	c.HTML(http.StatusOK, "profile.tmpl", gin.H{
-		"instance": instance,
-		"account":  account,
-		"statuses": statusResp.Items,
+		"instance":         instance,
+		"account":          account,
+		"statuses":         statusResp.Items,
+		"statuses_next":    statusResp.NextLink,
+		"show_back_to_top": showBackToTop,
 		"stylesheets": []string{
 			"/assets/Fork-Awesome/css/fork-awesome.min.css",
 			"/assets/dist/status.css",
diff --git a/web/source/css/profile.css b/web/source/css/profile.css
index ca4192e1..d0ab7e26 100644
--- a/web/source/css/profile.css
+++ b/web/source/css/profile.css
@@ -160,6 +160,24 @@ main {
 	}
 }
 
+.nothinghere {
+	margin-left: 1rem;
+}
+
+.backnextlinks {
+	display: flex;
+	flex-wrap: wrap;
+	justify-content: space-between;
+
+	a {
+		padding: 1rem;
+	}
+
+	.next {
+		margin-left: auto;
+	}
+}
+
 .toot, .toot:last-child {
 	box-shadow: $boxshadow;
 }
diff --git a/web/template/profile.tmpl b/web/template/profile.tmpl
index d65b45d5..458f68f5 100644
--- a/web/template/profile.tmpl
+++ b/web/template/profile.tmpl
@@ -27,13 +27,25 @@
             <div class="entry">Posted <b>{{.account.StatusesCount}}</b></div>
         </div>
     </div>
-    <h2 id="recent">Recent public toots</h2>
-	<div class="thread">
-		{{range .statuses}}
-		<div class="toot expanded">
-			{{ template "status.tmpl" .}}
-		</div>
-		{{end}}
-	</div>
+    <h2 id="recent">Latest public toots</h2>
+	    {{ if not .statuses }}
+        <div class="nothinghere">Nothing here!</div>
+        {{ else }}
+        <div class="thread">
+            {{ range .statuses }}
+            <div class="toot expanded">
+                {{ template "status.tmpl" .}}
+            </div>
+            {{ end }}
+        </div>
+        {{ end }}
+    <div class="backnextlinks">
+        {{ if .show_back_to_top }}
+        <a href="/@{{ .account.Username }}">Back to top</a>
+        {{ end }}
+        {{ if .statuses_next }}
+        <a href="{{ .statuses_next }}" class="next">Show older</a>
+        {{ end }}
+    </div>
 </main>
 {{ template "footer.tmpl" .}}