Skip to content

[BUG] - Performance: SSR becomes unusable with 100+ items on low-resource servers #667

@Manon-Arc

Description

@Manon-Arc

Description

When displaying 100+ items in a list view (e.g. Product model), the full SSR approach causes extremely high CPU usage on small servers (2 vCPU), making the page take 10+ seconds to load. The server CPU spikes to 140% during rendering.

The page should load in under 2 seconds, similar to other admin panels (Django Admin, AdminJS, Strapi) handling the same data volume.

Actual behavior
100 items: ~25s response time, CPU at 140%
50 items: acceptable but still slow (~5-8s)
25 items: fine
The bottleneck is not the database (queries return in <100ms). It is the SSR rendering: React renders 100+ table rows server-side, serializes them to HTML, then the client rehydrates everything.

Would it be possible to support a client-side pagination mode, where:

The server sends a lightweight JSON response (just the data)
The table rendering and pagination happen entirely on the client side
Only the data fetching goes through the server (API route)
This would be similar to how AdminJS or Strapi work : the server only acts as an API, and the browser handles the rendering. This would drastically reduce server CPU usage for list views.

Currently, i reduce defaultListSize to 25-50 and relying on pagination, but this limits the user experience

Reproduction URL

https://github.com/Manon-Arc/Test-nextAdmin.git

Reproduction steps

1. Configure a model with defaultListSize: 100 or higher
2. The model displays ~8 columns including 3 relations (entity, category, sub_category) with custom formatter
3. Load the list page on a 2 vCPU server

For example :
model Product {
  id               Int              @id @default(autoincrement())
  name             String           @db.VarChar(255)
  reference        String           @unique(map: "reference") @db.VarChar(100)
  buying_price     Decimal          @db.Decimal(10, 2)
  buying_price_sub Decimal          @db.Decimal(10, 2)
  image_url        String?          @db.VarChar(255)
  hide             Boolean          @default(false)
  id_category      Int
  id_sub_category  Int
  id_entity        Int
  category         Category         @relation(fields: [id_category], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "products_ibfk_1")
  sub_category     Sub_category     @relation(fields: [id_sub_category], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "products_ibfk_2")
  entity           Entity           @relation(fields: [id_entity], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "products_ibfk_3")
  quotation_line   Quotation_line[]
  attribut_value   Attribut_value[]

  @@index([reference], map: "reference_2")
  @@index([id_category], map: "idx_product_id_category")
  @@index([id_sub_category], map: "idx_product_id_sub_category")
  @@index([id_entity], map: "idx_product_id_entity")
  @@map("product")
}

Next router

App router

Next Admin version

8.4.2

Screenshots

Next Admin options

Product: {
      title: "Produits",
      icon: "ShoppingBagIcon",
      toString: (product) => `${product.name} (${product.reference})`,
      list: {
        defaultListSize: 150,
        search: ["name", "reference"] as any,
        display: ["id", "name", "reference", "buying_price", "buying_price_sub", "hide", "entity", "category", "sub_category"],
        fields: {
          entity: {
            formatter: (entity) => {
              return entity?.name || "Entité inconnue";
            },
          },
          category: {
            formatter: (category) => {
              return category?.name || "Catégorie inconnue";
            },
          },
          sub_category: {
            formatter: (sub_category) => {
              return sub_category ? `${sub_category.name} (${sub_category.type})` : "Sous-catégorie inconnue";
            },
          },
        },
      },
      edit: {
        display: ["name", "reference", "buying_price", "buying_price_sub", "hide", "entity", "category", "sub_category"],
        hooks: {
          beforeDb: async (data, mode) => {
            if (data.reference) {
              data.reference = data.reference.toString().trim();
            }
            if (data.name) {
              data.name = data.name.toString().trim();
            }
            return data;
          },
        },
      },
    },

Logs

Browsers

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions