diff --git a/internal/handler/response.go b/internal/handler/response.go
index cfa506f..6024a93 100644
--- a/internal/handler/response.go
+++ b/internal/handler/response.go
@@ -7,6 +7,7 @@ import (
 	"github.com/a-h/templ"
 	"github.com/ditatompel/xmr-remote-nodes/internal/handler/views"
 	"github.com/ditatompel/xmr-remote-nodes/internal/monero"
+	"github.com/ditatompel/xmr-remote-nodes/internal/paging"
 
 	"github.com/gofiber/fiber/v2"
 	"github.com/gofiber/fiber/v2/middleware/adaptor"
@@ -30,24 +31,6 @@ func (s *fiberServer) homeHandler(c *fiber.Ctx) error {
 	return handler(c)
 }
 
-// Render Remote Nodes Page
-func (s *fiberServer) remoteNodesHandler(c *fiber.Ctx) error {
-	p := views.Meta{
-		Title:       "Public Monero Remote Nodes List",
-		Description: "Although it's possible to use these existing public Monero nodes, you're MUST RUN AND USE YOUR OWN NODE!",
-		Keywords:    "monero remote nodes,public monero nodes,monero public nodes,monero wallet,tor monero node,monero cors rpc",
-		Robots:      "INDEX,FOLLOW",
-		Permalink:   "https://xmr.ditatompel.com/remote-nodes",
-		Identifier:  "/remote-nodes",
-	}
-
-	c.Set("Link", fmt.Sprintf(`<%s>; rel="canonical"`, p.Permalink))
-	home := views.BaseLayout(p, views.RemoteNodes())
-	handler := adaptor.HTTPHandler(templ.Handler(home))
-
-	return handler(c)
-}
-
 // Render Add Node Page
 func (s *fiberServer) addNodeHandler(c *fiber.Ctx) error {
 	p := views.Meta{
@@ -102,20 +85,78 @@ func Node(c *fiber.Ctx) error {
 	})
 }
 
-// Returns a list of nodes
+// Render Remote Nodes Page
+func (s *fiberServer) remoteNodesHandler(c *fiber.Ctx) error {
+	p := views.Meta{
+		Title:       "Public Monero Remote Nodes List",
+		Description: "Although it's possible to use these existing public Monero nodes, you're MUST RUN AND USE YOUR OWN NODE!",
+		Keywords:    "monero remote nodes,public monero nodes,monero public nodes,monero wallet,tor monero node,monero cors rpc",
+		Robots:      "INDEX,FOLLOW",
+		Permalink:   "https://xmr.ditatompel.com/remote-nodes",
+		Identifier:  "/remote-nodes",
+	}
+
+	moneroRepo := monero.New()
+	query := monero.QueryNodes{
+		Paging: paging.Paging{
+			Limit:         c.QueryInt("limit", 10), // rows per page
+			Page:          c.QueryInt("page", 1),
+			SortBy:        c.Query("sort_by", "id"),
+			SortDir:       c.Query("sort_dir", "desc"),
+			SortDirection: c.Query("sort_direction", "desc"), // deprecated
+			Refresh:       c.QueryInt("refresh", 0),
+		},
+		Host:     c.Query("host"),
+		Nettype:  c.Query("nettype", "any"),
+		Protocol: c.Query("protocol", "any"),
+		CC:       c.Query("cc", "any"),
+		Status:   c.QueryInt("status", -1),
+		CORS:     c.QueryInt("cors", -1),
+	}
+
+	nodes, err := moneroRepo.Nodes(query)
+	if err != nil {
+		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+			"status":  "error",
+			"message": err.Error(),
+			"data":    nil,
+		})
+	}
+
+	pagination := paging.NewPagination(query.Page, nodes.TotalPages)
+
+	// handle request from HTMX
+	if c.Get("HX-Target") == "tbl_nodes" {
+		cmp := views.BlankLayout(views.TableNodes(nodes, query, pagination))
+		handler := adaptor.HTTPHandler(templ.Handler(cmp))
+		return handler(c)
+	}
+
+	c.Set("Link", fmt.Sprintf(`<%s>; rel="canonical"`, p.Permalink))
+	home := views.BaseLayout(p, views.RemoteNodes(nodes, query, pagination))
+	handler := adaptor.HTTPHandler(templ.Handler(home))
+
+	return handler(c)
+}
+
+// Returns a list of nodes (API)
 func Nodes(c *fiber.Ctx) error {
 	moneroRepo := monero.New()
 	query := monero.QueryNodes{
-		RowsPerPage:   c.QueryInt("limit", 10),
-		Page:          c.QueryInt("page", 1),
-		SortBy:        c.Query("sort_by", "id"),
-		SortDirection: c.Query("sort_direction", "desc"),
-		Host:          c.Query("host"),
-		Nettype:       c.Query("nettype", "any"),
-		Protocol:      c.Query("protocol", "any"),
-		CC:            c.Query("cc", "any"),
-		Status:        c.QueryInt("status", -1),
-		CORS:          c.QueryInt("cors", -1),
+		Paging: paging.Paging{
+			Limit:         c.QueryInt("limit", 10), // rows per page
+			Page:          c.QueryInt("page", 1),
+			SortBy:        c.Query("sort_by", "id"),
+			SortDir:       c.Query("sort_dir", "desc"),
+			SortDirection: c.Query("sort_direction", "desc"), // deprecated
+			Refresh:       c.QueryInt("refresh", 0),
+		},
+		Host:     c.Query("host"),
+		Nettype:  c.Query("nettype", "any"),
+		Protocol: c.Query("protocol", "any"),
+		CC:       c.Query("cc", "any"),
+		Status:   c.QueryInt("status", -1),
+		CORS:     c.QueryInt("cors", -1),
 	}
 
 	nodes, err := moneroRepo.Nodes(query)
diff --git a/internal/handler/views/partial_datatable.templ b/internal/handler/views/partial_datatable.templ
new file mode 100644
index 0000000..b07e93f
--- /dev/null
+++ b/internal/handler/views/partial_datatable.templ
@@ -0,0 +1,70 @@
+package views
+
+import (
+	"fmt"
+	"github.com/ditatompel/xmr-remote-nodes/internal/paging"
+)
+
+var availablePages = []int{5, 10, 20, 50, 100}
+
+templ DtRowPerPage(url, hxTarget string, rowsPerPage int, q interface{}) {
+	<div class="max-w-sm space-y-3">
+		<select
+			name="limit"
+			id="dt_limit"
+			class="py-2 px-3 pe-9 block bg-neutral-900 border-neutral-700 rounded-lg text-sm focus:border-orange-400 focus:ring-orange-400"
+			hx-get={ fmt.Sprintf("%s?%s", url, paging.EncodedQuery(q, []string{"limit"})) }
+			hx-trigger="change"
+			hx-push-url="true"
+			hx-target={ hxTarget }
+			hx-swap="outerHTML"
+		>
+			<option disabled>CHOOSE</option>
+			for _, page := range availablePages {
+				<option
+					value={ fmt.Sprintf("%d", page) }
+					selected?={ page == rowsPerPage }
+				>{ fmt.Sprintf("%d", page) }</option>
+			}
+		</select>
+	</div>
+}
+
+templ DtRowCount(currentPage, rowsPerPage, totalRows int) {
+	<div>
+		<p class="text-sm">
+			if totalRows <= 0 {
+				No entries found
+			} else {
+				<b>{ fmt.Sprintf("%d", (rowsPerPage * currentPage) - rowsPerPage + 1) }</b>
+				if rowsPerPage * currentPage > totalRows {
+					- <b>{ fmt.Sprintf("%d", totalRows) }</b>
+				} else {
+					- <b>{ fmt.Sprintf("%d", rowsPerPage * currentPage) }</b>
+				}
+				<b>/ { fmt.Sprintf("%d", totalRows) }</b>
+			}
+		</p>
+	</div>
+}
+
+templ DtPagination(url, hxTarget string, q interface{}, p paging.Pagination) {
+	<div>
+		<nav class="pagination inline-flex gap-x-2">
+			for _, page := range p.Pages {
+				if page == -1 {
+					<button class="cursor-not-allowed" disabled>...</button>
+				} else if page == p.CurrentPage {
+					<button class="active" disabled>{ fmt.Sprintf("%d", page) }</button>
+				} else {
+					<button
+						hx-get={ fmt.Sprintf("%s?%s&page=%d", url, paging.EncodedQuery(q, []string{"page"}), page) }
+						hx-push-url="true"
+						hx-target={ hxTarget }
+						hx-swap="outerHTML"
+					>{ fmt.Sprintf("%d", page) }</button>
+				}
+			}
+		</nav>
+	</div>
+}
diff --git a/internal/handler/views/partial_datatable_templ.go b/internal/handler/views/partial_datatable_templ.go
new file mode 100644
index 0000000..2cd1341
--- /dev/null
+++ b/internal/handler/views/partial_datatable_templ.go
@@ -0,0 +1,333 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.778
+package views
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+	"fmt"
+	"github.com/ditatompel/xmr-remote-nodes/internal/paging"
+)
+
+var availablePages = []int{5, 10, 20, 50, 100}
+
+func DtRowPerPage(url, hxTarget string, rowsPerPage int, q interface{}) templ.Component {
+	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+			return templ_7745c5c3_CtxErr
+		}
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+		if !templ_7745c5c3_IsBuffer {
+			defer func() {
+				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+				if templ_7745c5c3_Err == nil {
+					templ_7745c5c3_Err = templ_7745c5c3_BufErr
+				}
+			}()
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var1 == nil {
+			templ_7745c5c3_Var1 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"max-w-sm space-y-3\"><select name=\"limit\" id=\"dt_limit\" class=\"py-2 px-3 pe-9 block bg-neutral-900 border-neutral-700 rounded-lg text-sm focus:border-orange-400 focus:ring-orange-400\" hx-get=\"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var2 string
+		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s?%s", url, paging.EncodedQuery(q, []string{"limit"})))
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/partial_datatable.templ`, Line: 16, Col: 80}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-trigger=\"change\" hx-push-url=\"true\" hx-target=\"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var3 string
+		templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(hxTarget)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/partial_datatable.templ`, Line: 19, Col: 23}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-swap=\"outerHTML\"><option disabled>CHOOSE</option> ")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		for _, page := range availablePages {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<option value=\"")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var4 string
+			templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", page))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/partial_datatable.templ`, Line: 25, Col: 36}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			if page == rowsPerPage {
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" selected")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(">")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var5 string
+			templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", page))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/partial_datatable.templ`, Line: 27, Col: 30}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</option>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</select></div>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		return templ_7745c5c3_Err
+	})
+}
+
+func DtRowCount(currentPage, rowsPerPage, totalRows int) templ.Component {
+	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+			return templ_7745c5c3_CtxErr
+		}
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+		if !templ_7745c5c3_IsBuffer {
+			defer func() {
+				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+				if templ_7745c5c3_Err == nil {
+					templ_7745c5c3_Err = templ_7745c5c3_BufErr
+				}
+			}()
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var6 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var6 == nil {
+			templ_7745c5c3_Var6 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div><p class=\"text-sm\">")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if totalRows <= 0 {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("No entries found")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+		} else {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<b>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var7 string
+			templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", (rowsPerPage*currentPage)-rowsPerPage+1))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/partial_datatable.templ`, Line: 39, Col: 73}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</b> ")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			if rowsPerPage*currentPage > totalRows {
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("- <b>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var8 string
+				templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalRows))
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/partial_datatable.templ`, Line: 41, Col: 40}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</b>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			} else {
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("- <b>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var9 string
+				templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", rowsPerPage*currentPage))
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/partial_datatable.templ`, Line: 43, Col: 56}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</b>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <b>/ ")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var10 string
+			templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalRows))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/partial_datatable.templ`, Line: 45, Col: 39}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</b>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p></div>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		return templ_7745c5c3_Err
+	})
+}
+
+func DtPagination(url, hxTarget string, q interface{}, p paging.Pagination) templ.Component {
+	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+			return templ_7745c5c3_CtxErr
+		}
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+		if !templ_7745c5c3_IsBuffer {
+			defer func() {
+				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+				if templ_7745c5c3_Err == nil {
+					templ_7745c5c3_Err = templ_7745c5c3_BufErr
+				}
+			}()
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var11 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var11 == nil {
+			templ_7745c5c3_Var11 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div><nav class=\"pagination inline-flex gap-x-2\">")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		for _, page := range p.Pages {
+			if page == -1 {
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<button class=\"cursor-not-allowed\" disabled>...</button>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			} else if page == p.CurrentPage {
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<button class=\"active\" disabled>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var12 string
+				templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", page))
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/partial_datatable.templ`, Line: 58, Col: 62}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</button>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			} else {
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<button hx-get=\"")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var13 string
+				templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s?%s&page=%d", url, paging.EncodedQuery(q, []string{"page"}), page))
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/partial_datatable.templ`, Line: 61, Col: 96}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-push-url=\"true\" hx-target=\"")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var14 string
+				templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(hxTarget)
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/partial_datatable.templ`, Line: 63, Col: 26}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-swap=\"outerHTML\">")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var15 string
+				templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", page))
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/partial_datatable.templ`, Line: 65, Col: 31}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</button>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</nav></div>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		return templ_7745c5c3_Err
+	})
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/handler/views/remote_nodes.templ b/internal/handler/views/remote_nodes.templ
index 943f3c4..6225f9d 100644
--- a/internal/handler/views/remote_nodes.templ
+++ b/internal/handler/views/remote_nodes.templ
@@ -1,6 +1,13 @@
 package views
 
-templ RemoteNodes() {
+import (
+	"fmt"
+	"github.com/ditatompel/xmr-remote-nodes/internal/monero"
+	"github.com/ditatompel/xmr-remote-nodes/internal/paging"
+	"time"
+)
+
+templ RemoteNodes(data monero.Nodes, q monero.QueryNodes, p paging.Pagination) {
 	<!-- Hero -->
 	<section class="relative overflow-hidden pt-6">
 		<!-- Gradients -->
@@ -30,4 +37,49 @@ templ RemoteNodes() {
 		</div>
 	</section>
 	<!-- End Hero -->
+	<div class="flex flex-col max-w-6xl mx-auto mb-10">
+		<div class="min-w-full inline-block align-middle">
+			@TableNodes(data, q, p)
+		</div>
+	</div>
+}
+
+templ TableNodes(data monero.Nodes, q monero.QueryNodes, p paging.Pagination) {
+	<div id="tbl_nodes" class="bg-neutral-800 border border-neutral-700 rounded-xl shadow-sm overflow-hidden">
+		<div class="px-6 py-4 grid gap-3 md:flex md:justify-between md:items-center border-b border-neutral-700">
+			@DtRowPerPage("/remote-nodes", "#tbl_nodes", q.Limit, q)
+		</div>
+		<div class="overflow-x-auto">
+			<table class="table-striped table-hover">
+				<thead>
+					<tr>
+						<th scope="col">Host:Port</th>
+						<th scope="col">Nettype</th>
+						<th scope="col">Protocol</th>
+						<th scope="col">Country</th>
+						<th scope="col">Status</th>
+						<th scope="col">Estimate Fee</th>
+						<th scope="col">Uptime</th>
+						<th scope="col">Check</th>
+					</tr>
+				</thead>
+				<tbody>
+					for _, row := range data.Items {
+						<tr>
+							<td>{ fmt.Sprintf("%s:%d", row.Hostname, row.Port) }</td>
+							<td>{ row.Nettype }<br/>{ fmt.Sprintf("%d", row.Height) } </td>
+							<td>{ row.Protocol }</td>
+							<td>{ row.CountryCode }</td>
+							<td>{ fmt.Sprintf("%d", row.EstimateFee) }</td>
+							<td>{ time.Unix(row.LastChecked, 0).Format("2006-01-02 15:04") }</td>
+						</tr>
+					}
+				</tbody>
+			</table>
+		</div>
+		<div class="px-6 py-4 grid gap-3 md:flex md:justify-between md:items-center border-t border-neutral-700">
+			@DtRowCount(p.CurrentPage, data.RowsPerPage, data.TotalRows)
+			@DtPagination("/remote-nodes", "#tbl_nodes", q, p)
+		</div>
+	</div>
 }
diff --git a/internal/handler/views/remote_nodes_templ.go b/internal/handler/views/remote_nodes_templ.go
index a70436c..3698a26 100644
--- a/internal/handler/views/remote_nodes_templ.go
+++ b/internal/handler/views/remote_nodes_templ.go
@@ -8,7 +8,14 @@ package views
 import "github.com/a-h/templ"
 import templruntime "github.com/a-h/templ/runtime"
 
-func RemoteNodes() templ.Component {
+import (
+	"fmt"
+	"github.com/ditatompel/xmr-remote-nodes/internal/monero"
+	"github.com/ditatompel/xmr-remote-nodes/internal/paging"
+	"time"
+)
+
+func RemoteNodes(data monero.Nodes, q monero.QueryNodes, p paging.Pagination) templ.Component {
 	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
 		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
 		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -29,7 +36,165 @@ func RemoteNodes() templ.Component {
 			templ_7745c5c3_Var1 = templ.NopComponent
 		}
 		ctx = templ.ClearChildren(ctx)
-		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!-- Hero --><section class=\"relative overflow-hidden pt-6\"><!-- Gradients --><div aria-hidden=\"true\" class=\"flex absolute -top-96 start-1/2 transform -translate-x-1/2\"><div class=\"bg-gradient-to-r blur-3xl w-[25rem] h-[44rem] rotate-[-60deg] transform -translate-x-[10rem] from-amber-800/30 to-orange-800/40\"></div><div class=\"bg-gradient-to-tl blur-3xl w-[90rem] h-[50rem] rounded-fulls origin-top-left -rotate-12 -translate-x-[15rem] from-orange-900/60 via-orange-900/40 to-amber-900/80\"></div></div><!-- End Gradients --><div class=\"relative z-10\"><div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-10 lg:py-16\"><div class=\"text-center\"><!-- Title --><div class=\"mt-5\"><h1 class=\"block font-extrabold text-4xl md:text-5xl lg:text-6xl text-neutral-200\">Public Monero Remote Nodes List</h1></div><!-- End Title --><div class=\"mt-5\"><p class=\"text-lg text-neutral-300\"><strong>Monero remote node</strong> is a device on the internet running the Monero software with full copy of the Monero blockchain that doesn't run on the same local machine where the Monero wallet is located.</p></div><hr class=\"mt-6\"></div><div class=\"max-w-3xl text-center mx-auto mt-8 prose prose-invert\"><p>Remote node can be used by people who, for their own reasons (usually because of hardware requirements, disk space, or technical abilities), cannot/don't want to run their own node and prefer to relay on one publicly available on the Monero network.</p><p>Using an open node will allow to make a transaction instantaneously, without the need to download the blockchain and sync to the Monero network first, but at the cost of the control over your privacy. the <strong>Monero community suggests to <span class=\"font-extrabold text-2xl underline decoration-double decoration-2 decoration-pink-500\">always run and use your own node</span></strong> to obtain the maximum possible privacy and to help decentralize the network.</p></div></div></div></section><!-- End Hero -->")
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!-- Hero --><section class=\"relative overflow-hidden pt-6\"><!-- Gradients --><div aria-hidden=\"true\" class=\"flex absolute -top-96 start-1/2 transform -translate-x-1/2\"><div class=\"bg-gradient-to-r blur-3xl w-[25rem] h-[44rem] rotate-[-60deg] transform -translate-x-[10rem] from-amber-800/30 to-orange-800/40\"></div><div class=\"bg-gradient-to-tl blur-3xl w-[90rem] h-[50rem] rounded-fulls origin-top-left -rotate-12 -translate-x-[15rem] from-orange-900/60 via-orange-900/40 to-amber-900/80\"></div></div><!-- End Gradients --><div class=\"relative z-10\"><div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-10 lg:py-16\"><div class=\"text-center\"><!-- Title --><div class=\"mt-5\"><h1 class=\"block font-extrabold text-4xl md:text-5xl lg:text-6xl text-neutral-200\">Public Monero Remote Nodes List</h1></div><!-- End Title --><div class=\"mt-5\"><p class=\"text-lg text-neutral-300\"><strong>Monero remote node</strong> is a device on the internet running the Monero software with full copy of the Monero blockchain that doesn't run on the same local machine where the Monero wallet is located.</p></div><hr class=\"mt-6\"></div><div class=\"max-w-3xl text-center mx-auto mt-8 prose prose-invert\"><p>Remote node can be used by people who, for their own reasons (usually because of hardware requirements, disk space, or technical abilities), cannot/don't want to run their own node and prefer to relay on one publicly available on the Monero network.</p><p>Using an open node will allow to make a transaction instantaneously, without the need to download the blockchain and sync to the Monero network first, but at the cost of the control over your privacy. the <strong>Monero community suggests to <span class=\"font-extrabold text-2xl underline decoration-double decoration-2 decoration-pink-500\">always run and use your own node</span></strong> to obtain the maximum possible privacy and to help decentralize the network.</p></div></div></div></section><!-- End Hero --><div class=\"flex flex-col max-w-6xl mx-auto mb-10\"><div class=\"min-w-full inline-block align-middle\">")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = TableNodes(data, q, p).Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		return templ_7745c5c3_Err
+	})
+}
+
+func TableNodes(data monero.Nodes, q monero.QueryNodes, p paging.Pagination) templ.Component {
+	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+			return templ_7745c5c3_CtxErr
+		}
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+		if !templ_7745c5c3_IsBuffer {
+			defer func() {
+				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+				if templ_7745c5c3_Err == nil {
+					templ_7745c5c3_Err = templ_7745c5c3_BufErr
+				}
+			}()
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var2 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var2 == nil {
+			templ_7745c5c3_Var2 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div id=\"tbl_nodes\" class=\"bg-neutral-800 border border-neutral-700 rounded-xl shadow-sm overflow-hidden\"><div class=\"px-6 py-4 grid gap-3 md:flex md:justify-between md:items-center border-b border-neutral-700\">")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = DtRowPerPage("/remote-nodes", "#tbl_nodes", q.Limit, q).Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div><div class=\"overflow-x-auto\"><table class=\"table-striped table-hover\"><thead><tr><th scope=\"col\">Host:Port</th><th scope=\"col\">Nettype</th><th scope=\"col\">Protocol</th><th scope=\"col\">Country</th><th scope=\"col\">Status</th><th scope=\"col\">Estimate Fee</th><th scope=\"col\">Uptime</th><th scope=\"col\">Check</th></tr></thead> <tbody>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		for _, row := range data.Items {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<tr><td>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var3 string
+			templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", row.Hostname, row.Port))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/remote_nodes.templ`, Line: 69, Col: 57}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var4 string
+			templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(row.Nettype)
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/remote_nodes.templ`, Line: 70, Col: 24}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<br>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var5 string
+			templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", row.Height))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/remote_nodes.templ`, Line: 70, Col: 62}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var6 string
+			templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(row.Protocol)
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/remote_nodes.templ`, Line: 71, Col: 25}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var7 string
+			templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(row.CountryCode)
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/remote_nodes.templ`, Line: 72, Col: 28}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var8 string
+			templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", row.EstimateFee))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/remote_nodes.templ`, Line: 73, Col: 47}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td><td>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var9 string
+			templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(time.Unix(row.LastChecked, 0).Format("2006-01-02 15:04"))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/handler/views/remote_nodes.templ`, Line: 74, Col: 69}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</td></tr>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</tbody></table></div><div class=\"px-6 py-4 grid gap-3 md:flex md:justify-between md:items-center border-t border-neutral-700\">")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = DtRowCount(p.CurrentPage, data.RowsPerPage, data.TotalRows).Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = DtPagination("/remote-nodes", "#tbl_nodes", q, p).Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div>")
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
diff --git a/internal/handler/views/src/css/main.css b/internal/handler/views/src/css/main.css
index 6aed813..d8681a6 100644
--- a/internal/handler/views/src/css/main.css
+++ b/internal/handler/views/src/css/main.css
@@ -25,3 +25,11 @@ a.btn-link {
 button.copy-input {
   @apply px-2 shrink-0 inline-flex justify-center items-center gap-x-2 text-sm font-semibold rounded-e-md border border-transparent bg-orange-600 text-white hover:brightness-125 focus:outline-none focus:bg-orange-700 disabled:opacity-50 disabled:pointer-events-none;
 }
+
+/* pagination */
+nav.pagination button.active {
+  @apply py-1.5 px-2 inline-flex items-center gap-x-2 text-sm font-bold rounded-lg border border-orange-500 bg-orange-500 text-white shadow-sm hover:brightness-125 disabled:opacity-90 disabled:pointer-events-none;
+}
+nav.pagination button {
+  @apply py-1.5 px-2 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg bg-neutral-800 border border-neutral-700 text-white shadow-sm hover:brightness-125 disabled:opacity-50 disabled:pointer-events-none;
+}
diff --git a/internal/monero/monero.go b/internal/monero/monero.go
index daec765..9f69e99 100644
--- a/internal/monero/monero.go
+++ b/internal/monero/monero.go
@@ -6,6 +6,7 @@ import (
 	"errors"
 	"fmt"
 	"log/slog"
+	"math"
 	"net"
 	"slices"
 	"strings"
@@ -13,6 +14,7 @@ import (
 
 	"github.com/ditatompel/xmr-remote-nodes/internal/database"
 	"github.com/ditatompel/xmr-remote-nodes/internal/ip"
+	"github.com/ditatompel/xmr-remote-nodes/internal/paging"
 	"github.com/jmoiron/sqlx/types"
 )
 
@@ -74,18 +76,13 @@ func (r *moneroRepo) Node(id int) (Node, error) {
 
 // QueryNodes represents database query parameters
 type QueryNodes struct {
-	Host     string
+	paging.Paging
+	Host     string `url:"host,omitempty"`
 	Nettype  string // Can be "any", mainnet, stagenet, testnet. Default: "any"
 	Protocol string // Can be "any", tor, http, https. Default: "any"
-	CC       string // 2 letter country code
+	CC       string `url:"cc,omitempty"` // 2 letter country code
 	Status   int
 	CORS     int
-
-	// pagination
-	RowsPerPage   int
-	Page          int
-	SortBy        string
-	SortDirection string
 }
 
 // toSQL generates SQL query from query parameters
@@ -133,8 +130,14 @@ func (q *QueryNodes) toSQL() (args []interface{}, where string) {
 	if !slices.Contains([]string{"last_checked", "uptime"}, q.SortBy) {
 		q.SortBy = "last_checked"
 	}
+
+	// deprecated: Use SortDir instead
 	if q.SortDirection != "asc" {
-		q.SortDirection = "DESC"
+		q.SortDir = "DESC"
+	}
+
+	if q.SortDir != "asc" {
+		q.SortDir = "DESC"
 	}
 
 	return args, where
@@ -143,6 +146,7 @@ func (q *QueryNodes) toSQL() (args []interface{}, where string) {
 // Nodes represents a list of nodes
 type Nodes struct {
 	TotalRows   int     `json:"total_rows"`
+	TotalPages  int     `json:"total_pages"` // total pages
 	RowsPerPage int     `json:"rows_per_page"`
 	Items       []*Node `json:"items"`
 }
@@ -153,7 +157,7 @@ func (r *moneroRepo) Nodes(q QueryNodes) (Nodes, error) {
 
 	var nodes Nodes
 
-	nodes.RowsPerPage = q.RowsPerPage
+	nodes.RowsPerPage = q.Limit
 
 	qTotal := fmt.Sprintf(`
 		SELECT
@@ -166,7 +170,8 @@ func (r *moneroRepo) Nodes(q QueryNodes) (Nodes, error) {
 	if err != nil {
 		return nodes, err
 	}
-	args = append(args, q.RowsPerPage, (q.Page-1)*q.RowsPerPage)
+	nodes.TotalPages = int(math.Ceil(float64(nodes.TotalRows) / float64(q.Limit)))
+	args = append(args, q.Limit, (q.Page-1)*q.Limit)
 
 	query := fmt.Sprintf(`
 		SELECT
@@ -178,7 +183,7 @@ func (r *moneroRepo) Nodes(q QueryNodes) (Nodes, error) {
 			%s
 			%s
 		LIMIT ?
-		OFFSET ?`, where, q.SortBy, q.SortDirection)
+		OFFSET ?`, where, q.SortBy, q.SortDir)
 	err = r.db.Select(&nodes.Items, query, args...)
 
 	return nodes, err
diff --git a/internal/monero/monero_test.go b/internal/monero/monero_test.go
index b9cddf5..ad0625c 100644
--- a/internal/monero/monero_test.go
+++ b/internal/monero/monero_test.go
@@ -8,6 +8,7 @@ import (
 
 	"github.com/ditatompel/xmr-remote-nodes/internal/config"
 	"github.com/ditatompel/xmr-remote-nodes/internal/database"
+	"github.com/ditatompel/xmr-remote-nodes/internal/paging"
 )
 
 var testMySQL = true
@@ -39,50 +40,56 @@ func init() {
 // go test -race ./internal/monero -run=TestQueryNodes_toSQL -v
 func TestQueryNodes_toSQL(t *testing.T) {
 	tests := []struct {
-		name              string
-		query             QueryNodes
-		wantArgs          []interface{}
-		wantWhere         string
-		wantSortBy        string
-		wantSortDirection string
+		name        string
+		query       QueryNodes
+		wantArgs    []interface{}
+		wantWhere   string
+		wantSortBy  string
+		wantSortDir string
 	}{
 		{
 			name: "Default query",
 			query: QueryNodes{
-				Host:          "",
-				Nettype:       "any",
-				Protocol:      "any",
-				CC:            "any",
-				Status:        -1,
-				CORS:          -1,
-				RowsPerPage:   10,
-				Page:          1,
-				SortBy:        "last_checked",
-				SortDirection: "desc",
+				Paging: paging.Paging{
+					Limit:         10,
+					Page:          1,
+					SortBy:        "last_checked",
+					SortDir:       "desc",
+					SortDirection: "desc", // deprecated
+				},
+				Host:     "",
+				Nettype:  "any",
+				Protocol: "any",
+				CC:       "any",
+				Status:   -1,
+				CORS:     -1,
 			},
-			wantArgs:          []interface{}{},
-			wantWhere:         "",
-			wantSortBy:        "last_checked",
-			wantSortDirection: "DESC",
+			wantArgs:    []interface{}{},
+			wantWhere:   "",
+			wantSortBy:  "last_checked",
+			wantSortDir: "DESC",
 		},
 		{
 			name: "With host query",
 			query: QueryNodes{
-				Host:          "test",
-				Nettype:       "any",
-				Protocol:      "any",
-				CC:            "any",
-				Status:        -1,
-				CORS:          -1,
-				RowsPerPage:   10,
-				Page:          1,
-				SortBy:        "last_checked",
-				SortDirection: "desc",
+				Paging: paging.Paging{
+					Limit:         10,
+					Page:          1,
+					SortBy:        "last_checked",
+					SortDir:       "desc",
+					SortDirection: "desc", // deprecated
+				},
+				Host:     "test",
+				Nettype:  "any",
+				Protocol: "any",
+				CC:       "any",
+				Status:   -1,
+				CORS:     -1,
 			},
-			wantArgs:          []interface{}{"%test%", "%test%"},
-			wantWhere:         "WHERE (hostname LIKE ? OR ip_addr LIKE ?)",
-			wantSortBy:        "last_checked",
-			wantSortDirection: "DESC",
+			wantArgs:    []interface{}{"%test%", "%test%"},
+			wantWhere:   "WHERE (hostname LIKE ? OR ip_addr LIKE ?)",
+			wantSortBy:  "last_checked",
+			wantSortDir: "DESC",
 		},
 	}
 	for _, tt := range tests {
@@ -97,8 +104,8 @@ func TestQueryNodes_toSQL(t *testing.T) {
 			if tt.query.SortBy != tt.wantSortBy {
 				t.Errorf("QueryNodes.toSQL() gotSortBy = %v, want %v", tt.query.SortBy, tt.wantSortBy)
 			}
-			if tt.query.SortDirection != tt.wantSortDirection {
-				t.Errorf("QueryNodes.toSQL() gotSortDirection = %v, want %v", tt.query.SortDirection, tt.wantSortDirection)
+			if tt.query.SortDir != tt.wantSortDir {
+				t.Errorf("QueryNodes.toSQL() gotSortDir = %v, want %v", tt.query.SortDir, tt.wantSortDir)
 			}
 		})
 	}
@@ -108,16 +115,19 @@ func TestQueryNodes_toSQL(t *testing.T) {
 // go test ./internal/monero -bench QueryNodes_toSQL -benchmem -run=^$ -v
 func Benchmark_QueryNodes_toSQL(b *testing.B) {
 	q := QueryNodes{
-		Host:          "test",
-		Nettype:       "any",
-		Protocol:      "any",
-		CC:            "any",
-		Status:        -1,
-		CORS:          -1,
-		RowsPerPage:   10,
-		Page:          1,
-		SortBy:        "last_checked",
-		SortDirection: "desc",
+		Paging: paging.Paging{
+			Limit:         10,
+			Page:          1,
+			SortBy:        "last_checked",
+			SortDir:       "desc",
+			SortDirection: "desc", // deprecated
+		},
+		Host:     "test",
+		Nettype:  "any",
+		Protocol: "any",
+		CC:       "any",
+		Status:   -1,
+		CORS:     -1,
 	}
 	for i := 0; i < b.N; i++ {
 		_, _ = q.toSQL()