diff --git a/NOTES.md b/NOTES.md
index 2eff90e73364471a3a945aac3e6f0d0ed710b4f0..4278c037b36430016f19f69a9e533c8699524e4b 100644
--- a/NOTES.md
+++ b/NOTES.md
@@ -28,12 +28,16 @@ repl.commands?
 ## Things to do
 
   * Run [godoc](http://localhost:6060/) and then review where the help text is lacking.
-  * Move to a storage layer.  Next: this.Folder should be a storage.Folder.
-  * Implement batch jobs - and have install populate the database with some
-    test data.
+  * ~~Move to a storage layer.~~
+  * this.Folder should be a storage.Folder.
+  * Implement batch jobs
+     * ~~Have install populate the database with some test data.~~
+     * reboot
+     * expire
   * Implement each command.
     * Next: folder commands - ~~CREATE~~, ~~REMOVE~~, MODIFY, ~~INDEX~~, ~~SELECT~~
-    * Messages: ~~ADD~~, CURRENT, ~~DIRECTORY~~, BACK, CHANGE, FIRST, REMOVE, NEXT, ~~READ~~
+    * Messages: ~~ADD~~, CURRENT, ~~DIRECTORY~~, BACK, CHANGE,
+                FIRST, REMOVE, ~~NEXT~~, ~~READ~~
     * Messages edit: CHANGE, REPLY, FORWARD
     * Moving messages: COPY, MOVE
     * Compound commands: SET and SHOW - make HELP work for them.
diff --git a/editor/tview.go b/editor/tview.go
index 50f3ab9b747f5130dc8b41fb654580053a345935..f062e75040c79e01d1b798e9aeda28f454692693 100644
--- a/editor/tview.go
+++ b/editor/tview.go
@@ -39,10 +39,8 @@ func Editor(placeholder, title string) (string, error) {
 	updateInfos := func() {
 		fromRow, fromColumn, toRow, toColumn := textArea.GetCursor()
 		if fromRow == toRow && fromColumn == toColumn {
-			// position.SetText(fmt.Sprintf("Row: [yellow]%d[white], Column: [yellow]%d ", fromRow, fromColumn))
 			position.SetText(fmt.Sprintf("Row: %d, Column: %d ", fromRow, fromColumn))
 		} else {
-			// position.SetText(fmt.Sprintf("[red]From[white] Row: [yellow]%d[white], Column: [yellow]%d[white] - [red]To[white] Row: [yellow]%d[white], To Column: [yellow]%d ", fromRow, fromColumn, toRow, toColumn))
 			position.SetText(fmt.Sprintf("From Row: %d, Column: %d - To Row: %d, To Column: %d ", fromRow, fromColumn, toRow, toColumn))
 		}
 	}
diff --git a/folders/messages.go b/folders/messages.go
index 3be061c4dc24d512ba742ff7f12fe4bb7436f42a..96499917d55096380fc20f4ab75d55d8c57593eb 100644
--- a/folders/messages.go
+++ b/folders/messages.go
@@ -62,6 +62,21 @@ func ReadMessage(login, folder string, msgid int64) (*storage.Message, error) {
 	return &msg, nil
 }
 
+// NextMsgid gets the next message id.
+func NextMsgid(login, folder string, msgid int64) int64 {
+	ctx := context.TODO()
+	newid, err := this.Q.NextMsgid(ctx, storage.NextMsgidParams{
+		Folder:   folder,
+		Folder_2: folder,
+		Login:    login,
+		ID:       msgid,
+	})
+	if err != nil {
+		return 0
+	}
+	return newid
+}
+
 // ListMessages lists messages.
 func ListMessages(folder string) ([]storage.Message, error) {
 	ctx := context.TODO()
diff --git a/key/key.go b/key/key.go
index 92f1373fb80bed002d5be8e2be129b1a31084653..2e716d95c8c290274ab9221fad92a5b011e4bb7c 100644
--- a/key/key.go
+++ b/key/key.go
@@ -9,7 +9,7 @@ import (
 	"github.com/adrg/xdg"
 )
 
-var keytemplate = `command="%s -u %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s\n`
+var keytemplate = `command="%s -u %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding %s\n`
 
 // Add adds an ssh key to the `authorized_keys` file.
 func Add(login, public string) error {
diff --git a/pager/tview.go b/pager/tview.go
new file mode 100644
index 0000000000000000000000000000000000000000..fd2634624acd32961d448f9b2d492bfc60355629
--- /dev/null
+++ b/pager/tview.go
@@ -0,0 +1,59 @@
+package pager
+
+import (
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+// Pager is the pager for text data.
+func Pager(text string) error {
+	theme := tview.Theme{
+		PrimitiveBackgroundColor:    tcell.ColorDefault,
+		ContrastBackgroundColor:     tcell.ColorDefault,
+		MoreContrastBackgroundColor: tcell.ColorDefault,
+		BorderColor:                 tcell.ColorDefault,
+		TitleColor:                  tcell.ColorDefault,
+		GraphicsColor:               tcell.ColorDefault,
+		PrimaryTextColor:            tcell.ColorDefault,
+		SecondaryTextColor:          tcell.ColorDefault,
+		TertiaryTextColor:           tcell.ColorDefault,
+		InverseTextColor:            tcell.ColorDefault,
+		ContrastSecondaryTextColor:  tcell.ColorDefault,
+	}
+	tview.Styles = theme
+	app := tview.NewApplication()
+
+	page := tview.NewTextView().
+		SetWrap(true).
+		SetScrollable(true).
+		SetChangedFunc(func() {
+			app.Draw()
+		}).
+		SetText(text)
+	// TODO: this doesn't seem to be working.
+	page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+		switch event.Key() {
+		case tcell.KeyRune:
+			switch event.Rune() {
+			case 'B', 'b':
+				_, y := page.GetScrollOffset()
+				_, _, _, height := page.GetInnerRect()
+				page.ScrollTo(0, max(y+height, 0))
+				return nil
+			case ' ':
+				_, y := page.GetScrollOffset()
+				_, _, _, height := page.GetInnerRect()
+				page.ScrollTo(0, y+height)
+				return nil
+			case 'Q', 'q':
+				app.Stop()
+				return nil
+			}
+		case tcell.KeyEsc, tcell.KeyCtrlC:
+			app.Stop()
+			return nil
+		}
+		return event
+	})
+	return app.SetRoot(page, true).Run()
+}
diff --git a/repl/messages.go b/repl/messages.go
index 019f176d98eb5a4da7514bd4381a92b26444e24e..4778a6d1fa1d8f9d7eed42d6148360d8ad587484 100644
--- a/repl/messages.go
+++ b/repl/messages.go
@@ -12,6 +12,7 @@ import (
 	"git.lyda.ie/kevin/bulletin/dclish"
 	"git.lyda.ie/kevin/bulletin/editor"
 	"git.lyda.ie/kevin/bulletin/folders"
+	"git.lyda.ie/kevin/bulletin/pager"
 	"git.lyda.ie/kevin/bulletin/this"
 )
 
@@ -172,13 +173,23 @@ func ActionFirst(cmd *dclish.Command) error {
 
 // ActionNext handles the `NEXT` command.
 func ActionNext(cmd *dclish.Command) error {
-	fmt.Printf("TODO: unimplemented...\n%s\n", cmd.Description)
+	// TODO: handle flags.
+	msgid := max(folders.NextMsgid(this.User.Login, this.Folder, this.MsgID), this.MsgID)
+	msg, err := folders.ReadMessage(this.User.Login, this.Folder, msgid)
+	if err != nil {
+		return err
+	}
+	// TODO: pager needs to report if the whole message was read
+	// and only increment if not.
+	pager.Pager(msg.String())
+	msgid = folders.NextMsgid(this.User.Login, this.Folder, msgid)
+	this.MsgID = max(this.MsgID, msgid)
 	return nil
 }
 
 // ActionRead handles the `READ` command.
 func ActionRead(cmd *dclish.Command) error {
-	// TODO: We need to set this.MsgID when we change folder.
+	// TODO: handle flags.
 	msgid := this.MsgID
 	if len(cmd.Args) == 1 {
 		id, err := strconv.Atoi(cmd.Args[0])
@@ -187,13 +198,15 @@ func ActionRead(cmd *dclish.Command) error {
 		}
 		msgid = int64(id)
 	}
-	msg, err := folders.ReadMessage(
-		this.User.Login, this.Folder, msgid)
+	msg, err := folders.ReadMessage(this.User.Login, this.Folder, msgid)
 	if err != nil {
 		return err
 	}
-	// TODO: update this.MsgID
-	fmt.Printf("%s\n", msg)
+	// TODO: pager needs to report if the whole message was read
+	// and only increment if not.
+	pager.Pager(msg.String())
+	msgid = folders.NextMsgid(this.User.Login, this.Folder, msgid)
+	this.MsgID = max(this.MsgID, msgid)
 	return nil
 }
 
diff --git a/storage/messages.sql.go b/storage/messages.sql.go
index cf675fa48f064e5da4419f484ea7885862124eac..5fd8d92b91983bd5465aeb8c327d9d5a5f7e2952 100644
--- a/storage/messages.sql.go
+++ b/storage/messages.sql.go
@@ -54,6 +54,31 @@ func (q *Queries) GetFirstMessageID(ctx context.Context, folder string) (int64,
 	return id, err
 }
 
+const nextMsgid = `-- name: NextMsgid :one
+SELECT CAST(COALESCE(MIN(id), 0) AS INT) FROM messages AS m
+  WHERE m.folder = ? AND m.id > ?
+  AND id NOT IN (SELECT id FROM seen AS s WHERE s.folder = ? AND s.login = ?)
+`
+
+type NextMsgidParams struct {
+	Folder   string
+	ID       int64
+	Folder_2 string
+	Login    string
+}
+
+func (q *Queries) NextMsgid(ctx context.Context, arg NextMsgidParams) (int64, error) {
+	row := q.db.QueryRowContext(ctx, nextMsgid,
+		arg.Folder,
+		arg.ID,
+		arg.Folder_2,
+		arg.Login,
+	)
+	var column_1 int64
+	err := row.Scan(&column_1)
+	return column_1, err
+}
+
 const readMessage = `-- name: ReadMessage :one
 SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages WHERE folder = ? AND id = ?
 `
diff --git a/storage/queries/messages.sql b/storage/queries/messages.sql
index 345619b3088c4c761ae6f83f6a0b905b8bc28101..62e04896ef8f2932930b782f1abc0f8d345d2022 100644
--- a/storage/queries/messages.sql
+++ b/storage/queries/messages.sql
@@ -13,3 +13,8 @@ SELECT id FROM messages WHERE folder = ? and id = MIN(id) GROUP BY folder;
 
 -- name: ReadMessage :one
 SELECT * FROM messages WHERE folder = ? AND id = ?;
+
+-- name: NextMsgid :one
+SELECT CAST(COALESCE(MIN(id), 0) AS INT) FROM messages AS m
+  WHERE m.folder = ? AND m.id > ?
+  AND id NOT IN (SELECT id FROM seen AS s WHERE s.folder = ? AND s.login = ?);