test: upstream list pages (#3056)

diff --git a/e2e/pom/upstreams.ts b/e2e/pom/upstreams.ts
new file mode 100644
index 0000000..c20de51
--- /dev/null
+++ b/e2e/pom/upstreams.ts
@@ -0,0 +1,51 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { uiGoto } from '@e2e/utils/ui';
+import { expect, type Page } from '@playwright/test';
+
+const locator = {
+  getUpstreamNavBtn: (page: Page) =>
+    page.getByRole('link', { name: 'Upstreams' }),
+  getAddUpstreamBtn: (page: Page) =>
+    page.getByRole('button', { name: 'Add Upstream' }),
+};
+
+const assert = {
+  isIndexPage: async (page: Page) => {
+    await expect(page).toHaveURL((url) => url.pathname.endsWith('/upstreams'));
+    const title = page.getByRole('heading', { name: 'Upstreams' });
+    await expect(title).toBeVisible();
+  },
+  isAddPage: async (page: Page) => {
+    await expect(page).toHaveURL((url) =>
+      url.pathname.endsWith('/upstreams/add')
+    );
+    const title = page.getByRole('heading', { name: 'Add Upstream' });
+    await expect(title).toBeVisible();
+  },
+};
+
+const goto = {
+  toIndex: (page: Page) => uiGoto(page, '/upstreams'),
+  toAdd: (page: Page) => uiGoto(page, '/upstreams/add'),
+};
+
+export const upstreamsPom = {
+  ...locator,
+  ...assert,
+  ...goto,
+};
diff --git a/e2e/tests/upstreams.list.spec.ts b/e2e/tests/upstreams.list.spec.ts
new file mode 100644
index 0000000..705c08c
--- /dev/null
+++ b/e2e/tests/upstreams.list.spec.ts
@@ -0,0 +1,94 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { upstreamsPom } from '@e2e/pom/upstreams';
+import { setupPaginationTests } from '@e2e/utils/pagination-test-helper';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect, type Page } from '@playwright/test';
+
+import { deleteAllUpstreams, putUpstreamReq } from '@/apis/upstreams';
+import { API_UPSTREAMS } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test('should navigate to upstreams page', async ({ page }) => {
+  await test.step('navigate to upstreams page', async () => {
+    await upstreamsPom.getUpstreamNavBtn(page).click();
+    await upstreamsPom.isIndexPage(page);
+  });
+
+  await test.step('verify upstreams page components', async () => {
+    await expect(upstreamsPom.getAddUpstreamBtn(page)).toBeVisible();
+
+    // list table exists
+    const table = page.getByRole('table');
+    await expect(table).toBeVisible();
+    await expect(table.getByText('ID', { exact: true })).toBeVisible();
+    await expect(table.getByText('Name', { exact: true })).toBeVisible();
+    await expect(table.getByText('Labels', { exact: true })).toBeVisible();
+    await expect(table.getByText('Actions', { exact: true })).toBeVisible();
+  });
+});
+
+const upstreams: APISIXType['Upstream'][] = Array.from(
+  { length: 11 },
+  (_, i) => ({
+    id: `upstream_id_${i + 1}`,
+    name: `upstream_name_${i + 1}`,
+    nodes: [
+      {
+        host: `node_${i + 1}`,
+        port: 80,
+        weight: 100,
+      },
+    ],
+  })
+);
+
+test.describe('page and page_size should work correctly', () => {
+  test.describe.configure({ mode: 'serial' });
+  test.beforeAll(async () => {
+    await deleteAllUpstreams(e2eReq);
+    await Promise.all(upstreams.map((d) => putUpstreamReq(e2eReq, d)));
+  });
+
+  test.afterAll(async () => {
+    await Promise.all(
+      upstreams.map((d) => e2eReq.delete(`${API_UPSTREAMS}/${d.id}`))
+    );
+  });
+
+  // Setup pagination tests with upstream-specific configurations
+  const filterItemsNotInPage = async (page: Page) => {
+    // filter the item which not in the current page
+    // it should be random, so we need get all items in the table
+    const itemsInPage = await page
+      .getByRole('cell', { name: /upstream_name_/ })
+      .all();
+    const names = await Promise.all(itemsInPage.map((v) => v.textContent()));
+    return upstreams.filter((d) => !names.includes(d.name));
+  };
+
+  setupPaginationTests(test, {
+    pom: upstreamsPom,
+    items: upstreams,
+    filterItemsNotInPage,
+    getCell: (page, item) =>
+      page.getByRole('cell', { name: item.name }).first(),
+  });
+});
+
diff --git a/e2e/utils/common.ts b/e2e/utils/common.ts
index 6646fc2..3528a7c 100644
--- a/e2e/utils/common.ts
+++ b/e2e/utils/common.ts
@@ -14,9 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { readFile } from 'node:fs/promises';
+import { access, readFile } from 'node:fs/promises';
 import path from 'node:path';
 
+import { nanoid } from 'nanoid';
 import { parse } from 'yaml';
 
 type APISIXConf = {
@@ -29,3 +30,14 @@
   const res = parse(file) as APISIXConf;
   return { adminKey: res.deployment.admin.admin_key[0].key };
 };
+
+export const fileExists = async (filePath: string) => {
+  try {
+    await access(filePath);
+    return true;
+  } catch {
+    return false;
+  }
+};
+
+export const randomId = (info: string) => `${info}_${nanoid()}`;
diff --git a/e2e/utils/pagination-test-helper.ts b/e2e/utils/pagination-test-helper.ts
new file mode 100644
index 0000000..28c1226
--- /dev/null
+++ b/e2e/utils/pagination-test-helper.ts
@@ -0,0 +1,230 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { test as realTest } from './test';
+
+export interface PaginationTestConfig<T> {
+  pom: {
+    toIndex: (page: Page) => Promise<unknown>;
+    isIndexPage: (page: Page) => Promise<void>;
+  };
+  items: T[];
+  filterItemsNotInPage: (page: Page) => Promise<T[]>;
+  getCell: (page: Page, item: T) => Locator;
+}
+
+export function setupPaginationTests<T>(
+  test: typeof realTest,
+  { pom, items, filterItemsNotInPage, getCell }: PaginationTestConfig<T>
+) {
+  const defaultPageNum = 1;
+  const defaultPageSize = 10;
+  const newPageSize = 20;
+  const newPageNum = 2;
+
+  const getPageSizeSelection = (page: Page, size: number) => {
+    return page
+      .locator('.ant-select-selection-item')
+      .filter({ hasText: new RegExp(`${size} / page`) })
+      .first();
+  };
+
+  const getPageSizeOption = (page: Page, size: number) => {
+    return page.getByRole('option', { name: `${size} / page` });
+  };
+
+  const getPageNum = (page: Page, num: number) => {
+    return page.getByRole('listitem', { name: `${num}` });
+  };
+
+  const itemIsVisible = async (page: Page, item: T) => {
+    const cell = getCell(page, item);
+    await expect(cell).toBeVisible();
+  };
+
+  const itemIsHidden = async (page: Page, item: T) => {
+    const cell = getCell(page, item);
+    await expect(cell).toBeHidden();
+  };
+
+  test('can use the pagination of the table to switch', async ({ page }) => {
+    await test.step('navigate to list page', async () => {
+      await pom.toIndex(page);
+      await pom.isIndexPage(page);
+    });
+
+    await test.step('default page info should exists', async () => {
+      // page_size should exist in url
+      await expect(page).toHaveURL(
+        (url) =>
+          url.searchParams.get('page_size') === defaultPageSize.toString()
+      );
+      // page_size should exist in table
+      await expect(getPageSizeSelection(page, defaultPageSize)).toBeVisible();
+
+      // pageNum should exist in url
+      await expect(page).toHaveURL(
+        (url) => url.searchParams.get('page') === defaultPageNum.toString()
+      );
+      // pageNum should exist in table
+      await expect(getPageNum(page, defaultPageNum)).toBeVisible();
+
+      const itemsNotInPage = await filterItemsNotInPage(page);
+      // items not in page should not be visible
+      for (const item of itemsNotInPage) {
+        await itemIsHidden(page, item);
+      }
+    });
+
+    await test.step(`can switch page size to ${newPageSize}`, async () => {
+      // click page size selection, then click new page size option
+      await getPageSizeSelection(page, defaultPageSize).click();
+      await getPageSizeOption(page, newPageSize).click();
+
+      await expect(getPageSizeSelection(page, newPageSize)).toBeVisible();
+      await expect(getPageNum(page, defaultPageNum)).toBeVisible();
+      // old page_size should be hidden
+      await expect(getPageSizeSelection(page, defaultPageSize)).toBeHidden();
+
+      // page_size should exist in url
+      await expect(page).toHaveURL(
+        (url) => url.searchParams.get('page_size') === newPageSize.toString()
+      );
+      // pageNum should exist in url
+      await expect(page).toHaveURL(
+        (url) => url.searchParams.get('page') === defaultPageNum.toString()
+      );
+
+      // all items should be visible
+      for (const item of items) {
+        await itemIsVisible(page, item);
+      }
+    });
+
+    await test.step('switch to default', async () => {
+      await getPageSizeSelection(page, newPageSize).click();
+      await getPageSizeOption(page, defaultPageSize).click();
+
+      await expect(getPageSizeSelection(page, defaultPageSize)).toBeVisible();
+      await expect(getPageNum(page, defaultPageNum)).toBeVisible();
+      await expect(getPageSizeSelection(page, newPageSize)).toBeHidden();
+    });
+
+    await test.step(`can switch page num to ${newPageNum}`, async () => {
+      const itemsNotInPage = await filterItemsNotInPage(page);
+      // click page num
+      await getPageNum(page, defaultPageNum).click();
+      await getPageNum(page, newPageNum).click();
+
+      // pageNum should exist in url
+      await expect(page).toHaveURL(
+        (url) => url.searchParams.get('page') === newPageNum.toString()
+      );
+      await pom.isIndexPage(page);
+
+      // items not in page should be visible
+      for (const item of itemsNotInPage) {
+        await itemIsVisible(page, item);
+      }
+    });
+  });
+
+  test('can use the search params in the URL to switch', async ({ page }) => {
+    await test.step('navigate to list page', async () => {
+      await pom.toIndex(page);
+      await pom.isIndexPage(page);
+    });
+
+    await test.step('default page info should exists', async () => {
+      // page_size should exist in url
+      await expect(page).toHaveURL(
+        (url) =>
+          url.searchParams.get('page_size') === defaultPageSize.toString()
+      );
+      // page_size should exist in table
+      await expect(getPageSizeSelection(page, defaultPageSize)).toBeVisible();
+
+      // pageNum should exist in url
+      await expect(page).toHaveURL(
+        (url) => url.searchParams.get('page') === defaultPageNum.toString()
+      );
+      // pageNum should exist in table
+      await expect(getPageNum(page, defaultPageNum)).toBeVisible();
+
+      // items not in page should not be visible
+      const itemsNotInPage = await filterItemsNotInPage(page);
+      for (const item of itemsNotInPage) {
+        await itemIsHidden(page, item);
+      }
+    });
+
+    await test.step(`can switch page size to ${newPageSize}`, async () => {
+      const url = new URL(page.url());
+      url.searchParams.set('page_size', newPageSize.toString());
+      await page.goto(url.toString());
+
+      // check pagination
+      await expect(getPageSizeSelection(page, newPageSize)).toBeVisible();
+      await expect(getPageNum(page, defaultPageNum)).toBeVisible();
+      await expect(getPageSizeSelection(page, defaultPageSize)).toBeHidden();
+
+      // pagination should exist in url
+      await expect(page).toHaveURL((url) => {
+        return (
+          url.searchParams.get('page_size') === newPageSize.toString() &&
+          url.searchParams.get('page') === defaultPageNum.toString()
+        );
+      });
+
+      // all items should be visible
+      for (const item of items) {
+        await itemIsVisible(page, item);
+      }
+    });
+
+    await test.step('switch to default', async () => {
+      const url = new URL(page.url());
+      url.searchParams.set('page_size', defaultPageSize.toString());
+      await page.goto(url.toString());
+
+      await expect(getPageSizeSelection(page, defaultPageSize)).toBeVisible();
+      await expect(getPageNum(page, defaultPageNum)).toBeVisible();
+      await expect(getPageSizeSelection(page, newPageSize)).toBeHidden();
+    });
+
+    await test.step(`can switch page num to ${newPageNum}`, async () => {
+      const itemsNotInPage = await filterItemsNotInPage(page);
+
+      const url = new URL(page.url());
+      url.searchParams.set('page', newPageNum.toString());
+      await page.goto(url.toString());
+
+      // pageNum should exist in url
+      await expect(page).toHaveURL(
+        (url) => url.searchParams.get('page') === newPageNum.toString()
+      );
+      await pom.isIndexPage(page);
+
+      // items not in page should be visible
+      for (const item of itemsNotInPage) {
+        await itemIsVisible(page, item);
+      }
+    });
+  });
+}
diff --git a/e2e/utils/test.ts b/e2e/utils/test.ts
index b6ddd2e..5abc1ad 100644
--- a/e2e/utils/test.ts
+++ b/e2e/utils/test.ts
@@ -14,12 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { existsSync } from 'node:fs';
+import { readFile } from 'node:fs/promises';
 import path from 'node:path';
 
-import { test as baseTest } from '@playwright/test';
+import { expect, test as baseTest } from '@playwright/test';
 
-import { getAPISIXConf } from './common';
+import { fileExists, getAPISIXConf } from './common';
 import { env } from './env';
 
 export const test = baseTest.extend<object, { workerStorageState: string }>({
@@ -32,9 +32,14 @@
         test.info().project.outputDir,
         `.auth/${id}.json`
       );
+      const { adminKey } = await getAPISIXConf();
 
-      if (existsSync(fileName)) {
-        return await use(fileName);
+      // file exists and contains admin key, use it
+      if (
+        (await fileExists(fileName)) &&
+        (await readFile(fileName)).toString().includes(adminKey)
+      ) {
+        return use(fileName);
       }
 
       const page = await browser.newPage({ storageState: undefined });
@@ -44,19 +49,14 @@
 
       // we need to authenticate
       const settingsModal = page.getByRole('dialog', { name: 'Settings' });
-      if (await settingsModal.isVisible()) {
-        const { adminKey } = await getAPISIXConf();
-
-        const adminKeyInput = page.getByRole('textbox', { name: 'Admin Key' });
-        await adminKeyInput.clear();
-        await adminKeyInput.fill(adminKey);
-        await page
-          .getByRole('dialog', { name: 'Settings' })
-          .getByRole('button')
-          .click();
-
-        await page.reload();
-      }
+      await expect(settingsModal).toBeVisible();
+      const adminKeyInput = page.getByRole('textbox', { name: 'Admin Key' });
+      await adminKeyInput.clear();
+      await adminKeyInput.fill(adminKey);
+      await page
+        .getByRole('dialog', { name: 'Settings' })
+        .getByRole('button')
+        .click();
 
       await page.context().storageState({ path: fileName });
       await page.close();
diff --git a/e2e/utils/ui.ts b/e2e/utils/ui.ts
new file mode 100644
index 0000000..015601b
--- /dev/null
+++ b/e2e/utils/ui.ts
@@ -0,0 +1,25 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import type { Page } from '@playwright/test';
+
+import type { FileRouteTypes } from '@/routeTree.gen';
+
+import { env } from './env';
+
+export const uiGoto = (page: Page, path: FileRouteTypes['to']) => {
+  return page.goto(`${env.E2E_TARGET_URL}${path.substring(1)}`);
+};
diff --git a/src/apis/hooks.ts b/src/apis/hooks.ts
new file mode 100644
index 0000000..6b9cd43
--- /dev/null
+++ b/src/apis/hooks.ts
@@ -0,0 +1,38 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
+
+import { getUpstreamListReq } from '@/apis/upstreams';
+import { req } from '@/config/req';
+import { type PageSearchType } from '@/types/schema/pageSearch';
+import { useSearchParams } from '@/utils/useSearchParams';
+import { useTablePagination } from '@/utils/useTablePagination';
+
+export const getUpstreamListQueryOptions = (props: PageSearchType) => {
+  return queryOptions({
+    queryKey: ['upstreams', props.page, props.page_size],
+    queryFn: () => getUpstreamListReq(req, props),
+  });
+};
+
+export const useUpstreamList = () => {
+  const { params, setParams } = useSearchParams('/upstreams/');
+  const upstreamQuery = useSuspenseQuery(getUpstreamListQueryOptions(params));
+  const { data, isLoading, refetch } = upstreamQuery;
+  const pagination = useTablePagination({ data, setParams, params });
+  return { data, isLoading, refetch, pagination };
+};
diff --git a/src/apis/upstreams.ts b/src/apis/upstreams.ts
index c893e6b..1938b38 100644
--- a/src/apis/upstreams.ts
+++ b/src/apis/upstreams.ts
@@ -14,27 +14,51 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { queryOptions } from '@tanstack/react-query';
+
+import type { AxiosInstance } from 'axios';
 
 import { API_UPSTREAMS } from '@/config/constant';
-import { req } from '@/config/req';
 import type { APISIXType } from '@/types/schema/apisix';
+import type { PageSearchType } from '@/types/schema/pageSearch';
 
-export const getUpstreamReq = (id: string) =>
-  queryOptions({
-    queryKey: ['upstream', id],
-    queryFn: () =>
-      req
-        .get<unknown, APISIXType['RespUpstreamDetail']>(
-          `${API_UPSTREAMS}/${id}`
-        )
-        .then((v) => v.data),
-  });
+export const getUpstreamListReq = (req: AxiosInstance, params: PageSearchType) =>
+  req
+    .get<undefined, APISIXType['RespUpstreamList']>(API_UPSTREAMS, { params })
+    .then((v) => v.data);
 
-export const putUpstreamReq = (data: APISIXType['Upstream']) => {
+export const getUpstreamReq = (req: AxiosInstance, id: string) =>
+  req
+    .get<unknown, APISIXType['RespUpstreamDetail']>(`${API_UPSTREAMS}/${id}`)
+    .then((v) => v.data);
+
+export const postUpstreamReq = (
+  req: AxiosInstance,
+  data: Partial<APISIXType['Upstream']>
+) =>
+  req.post<APISIXType['Upstream'], APISIXType['RespUpstreamDetail']>(
+    API_UPSTREAMS,
+    data
+  );
+
+export const putUpstreamReq = (
+  req: AxiosInstance,
+  data: APISIXType['Upstream']
+) => {
   const { id, ...rest } = data;
   return req.put<APISIXType['Upstream'], APISIXType['RespUpstreamDetail']>(
     `${API_UPSTREAMS}/${id}`,
     rest
   );
 };
+
+export const deleteAllUpstreams = async (req: AxiosInstance) => {
+  const res = await getUpstreamListReq(req, {
+    page: 1,
+    page_size: 1000,
+    pageSize: 1000,
+  });
+  if (res.total === 0) return;
+  return await Promise.all(
+    res.list.map((d) => req.delete(`${API_UPSTREAMS}/${d.value.id}`))
+  );
+};
diff --git a/src/components/page/ToAddPageBtn.tsx b/src/components/page/ToAddPageBtn.tsx
index 8767d31..8a071d3 100644
--- a/src/components/page/ToAddPageBtn.tsx
+++ b/src/components/page/ToAddPageBtn.tsx
@@ -21,9 +21,6 @@
 import type { FileRoutesByTo } from '@/routeTree.gen';
 import IconPlus from '~icons/material-symbols/add';
 
-type FilterKeys<T, R extends string> = {
-  [K in keyof T as K extends `${string}${R}` ? K : never]: T[K];
-};
 type ToAddPageBtnProps = {
   to: keyof FilterKeys<FileRoutesByTo, 'add'>;
   label: string;
diff --git a/src/routes/upstreams/add.tsx b/src/routes/upstreams/add.tsx
index 55d1c7f..2445040 100644
--- a/src/routes/upstreams/add.tsx
+++ b/src/routes/upstreams/add.tsx
@@ -22,14 +22,13 @@
 import { useTranslation } from 'react-i18next';
 import type { z } from 'zod';
 
+import { postUpstreamReq } from '@/apis/upstreams';
 import { FormSubmitBtn } from '@/components/form/Btn';
 import { FormPartUpstream } from '@/components/form-slice/FormPartUpstream';
 import { FormPartUpstreamSchema } from '@/components/form-slice/FormPartUpstream/schema';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import PageHeader from '@/components/page/PageHeader';
-import { API_UPSTREAMS } from '@/config/constant';
 import { req } from '@/config/req';
-import { type APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
 const PostUpstreamSchema = FormPartUpstreamSchema.omit({
@@ -42,8 +41,7 @@
   const { t } = useTranslation();
   const router = useRouter();
   const postUpstream = useMutation({
-    mutationFn: (data: PostUpstreamType) =>
-      req.post<unknown, APISIXType['RespUpstreamDetail']>(API_UPSTREAMS, data),
+    mutationFn: (data: PostUpstreamType) => postUpstreamReq(req, data),
     async onSuccess(data) {
       notifications.show({
         message: t('info.add.success', { name: t('upstreams.singular') }),
diff --git a/src/routes/upstreams/detail.$id.tsx b/src/routes/upstreams/detail.$id.tsx
index 1ac7bcd..7d8e539 100644
--- a/src/routes/upstreams/detail.$id.tsx
+++ b/src/routes/upstreams/detail.$id.tsx
@@ -17,7 +17,11 @@
 import { zodResolver } from '@hookform/resolvers/zod';
 import { Button, Group,Skeleton } from '@mantine/core';
 import { notifications } from '@mantine/notifications';
-import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
+import {
+  queryOptions,
+  useMutation,
+  useSuspenseQuery,
+} from '@tanstack/react-query';
 import {
   createFileRoute,
   useNavigate,
@@ -38,6 +42,7 @@
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
 import { API_UPSTREAMS } from '@/config/constant';
+import { req } from '@/config/req';
 import type { APISIXType } from '@/types/schema/apisix';
 import { pipeProduce } from '@/utils/producer';
 
@@ -46,6 +51,12 @@
   setReadOnly: (v: boolean) => void;
 };
 
+const getUpstreamQueryOptions = (id: string) =>
+  queryOptions({
+    queryKey: ['upstream', id],
+    queryFn: () => getUpstreamReq(req, id),
+  });
+
 const UpstreamDetailForm = (
   props: Props & Pick<APISIXType['Upstream'], 'id'>
 ) => {
@@ -55,7 +66,7 @@
     data: { value: upstreamData },
     isLoading,
     refetch,
-  } = useSuspenseQuery(getUpstreamReq(id));
+  } = useSuspenseQuery(getUpstreamQueryOptions(id));
 
   const form = useForm({
     resolver: zodResolver(FormPartUpstreamSchema),
@@ -65,7 +76,7 @@
   });
 
   const putUpstream = useMutation({
-    mutationFn: putUpstreamReq,
+    mutationFn: (data: APISIXType['Upstream']) => putUpstreamReq(req, data),
     async onSuccess() {
       notifications.show({
         message: t('info.edit.success', { name: t('upstreams.singular') }),
diff --git a/src/routes/upstreams/index.tsx b/src/routes/upstreams/index.tsx
index 9b7eeb3..74bca7b 100644
--- a/src/routes/upstreams/index.tsx
+++ b/src/routes/upstreams/index.tsx
@@ -16,57 +16,23 @@
  */
 import type { ProColumns } from '@ant-design/pro-components';
 import { ProTable } from '@ant-design/pro-components';
-import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
 import { createFileRoute } from '@tanstack/react-router';
-import { useEffect, useMemo } from 'react';
+import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
+import { getUpstreamListQueryOptions, useUpstreamList } from '@/apis/hooks';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
-import { ToAddPageBtn,ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
+import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
 import { AntdConfigProvider } from '@/config/antdConfigProvider';
 import { API_UPSTREAMS } from '@/config/constant';
 import { queryClient } from '@/config/global';
-import { req } from '@/config/req';
 import type { APISIXType } from '@/types/schema/apisix';
-import {
-  pageSearchSchema,
-  type PageSearchType,
-} from '@/types/schema/pageSearch';
-import { usePagination } from '@/utils/usePagination';
-
-const genUpstreamsQueryOptions = (props: PageSearchType) => {
-  const { page, pageSize } = props;
-  return queryOptions({
-    queryKey: ['upstreams', page, pageSize],
-    queryFn: () =>
-      req
-        .get<unknown, APISIXType['RespUpstreamList']>(API_UPSTREAMS, {
-          params: {
-            page,
-            page_size: pageSize,
-          },
-        })
-        .then((v) => v.data),
-  });
-};
+import { pageSearchSchema } from '@/types/schema/pageSearch';
 
 function RouteComponent() {
   const { t } = useTranslation();
-
-  // Use the pagination hook
-  const { pagination, handlePageChange, updateTotal } = usePagination({
-    queryKey: 'upstreams',
-  });
-
-  const upstreamQuery = useSuspenseQuery(genUpstreamsQueryOptions(pagination));
-  const { data, isLoading, refetch } = upstreamQuery;
-
-  useEffect(() => {
-    if (data?.total) {
-      updateTotal(data.total);
-    }
-  }, [data?.total, updateTotal]);
+  const { data, isLoading, refetch, pagination } = useUpstreamList();
 
   const columns = useMemo<
     ProColumns<APISIXType['RespUpstreamList']['data']['list'][number]>[]
@@ -101,7 +67,6 @@
         title: t('form.upstreams.updateTime'),
         key: 'update_time',
         valueType: 'dateTime',
-        sorter: true,
         renderText: (text) => {
           if (!text) return '-';
           return new Date(Number(text) * 1000).toISOString();
@@ -141,13 +106,7 @@
           loading={isLoading}
           search={false}
           options={false}
-          pagination={{
-            current: pagination.page,
-            pageSize: pagination.pageSize,
-            total: pagination.total,
-            showSizeChanger: true,
-            onChange: handlePageChange,
-          }}
+          pagination={pagination}
           cardProps={{ bodyStyle: { padding: 0 } }}
           toolbar={{
             menu: {
@@ -179,5 +138,5 @@
   validateSearch: pageSearchSchema,
   loaderDeps: ({ search }) => search,
   loader: ({ deps }) =>
-    queryClient.ensureQueryData(genUpstreamsQueryOptions(deps)),
+    queryClient.ensureQueryData(getUpstreamListQueryOptions(deps)),
 });
diff --git a/src/types/schema/pageSearch.ts b/src/types/schema/pageSearch.ts
index 3bc27d2..dc2f25b 100644
--- a/src/types/schema/pageSearch.ts
+++ b/src/types/schema/pageSearch.ts
@@ -16,19 +16,39 @@
  */
 import { z } from 'zod';
 
-export const pageSearchSchema = z.object({
-  page: z
-    .union([z.string(), z.number()])
-    .optional()
-    .default(1)
-    .transform((val) => (val ? Number(val) : 1)),
-  pageSize: z
-    .union([z.string(), z.number()])
-    .optional()
-    .default(10)
-    .transform((val) => (val ? Number(val) : 10)),
-  name: z.string().optional(),
-  label: z.string().optional(),
-});
+/**
+ * To deprecate pageSize without modifying existing code, use preprocessing.
+ */
+export const pageSearchSchema = z.preprocess(
+  (data) => {
+    // If pageSize is provided but page_size isn't, use pageSize value for page_size
+    const inputData = data as Record<string, unknown>;
+    if (inputData?.pageSize && inputData?.page_size === undefined) {
+      return { ...inputData, page_size: inputData.pageSize };
+    }
+    return data;
+  },
+  z
+    .object({
+      page: z
+        .union([z.string(), z.number()])
+        .optional()
+        .default(1)
+        .transform((val) => (val ? Number(val) : 1)),
+      pageSize: z
+        .union([z.string(), z.number()])
+        .optional()
+        .default(10)
+        .transform((val) => (val ? Number(val) : 10)),
+      page_size: z
+        .union([z.string(), z.number()])
+        .optional()
+        .default(10)
+        .transform((val) => (val ? Number(val) : 10)),
+      name: z.string().optional(),
+      label: z.string().optional(),
+    })
+    .passthrough()
+);
 
 export type PageSearchType = z.infer<typeof pageSearchSchema>;
diff --git a/src/types/vite-env.d.ts b/src/types/vite-env.d.ts
index 299c06c..70155fb 100644
--- a/src/types/vite-env.d.ts
+++ b/src/types/vite-env.d.ts
@@ -16,3 +16,7 @@
  */
 /// <reference types="vite/client" />
 /// <reference types="unplugin-info/client" />
+
+type FilterKeys<T, R extends string> = {
+  [K in keyof T as K extends `${string}${R}` ? K : never]: T[K];
+};
diff --git a/src/utils/usePagination.ts b/src/utils/usePagination.ts
index 0d8abba..d30bfd0 100644
--- a/src/utils/usePagination.ts
+++ b/src/utils/usePagination.ts
@@ -75,6 +75,7 @@
 
   const [pagination, setPagination] = useState<PaginationState>({
     page: urlPage,
+    page_size: urlPageSize,
     pageSize: urlPageSize,
     total: initialTotal,
   });
diff --git a/src/utils/useSearchParams.ts b/src/utils/useSearchParams.ts
new file mode 100644
index 0000000..35fc831
--- /dev/null
+++ b/src/utils/useSearchParams.ts
@@ -0,0 +1,54 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+  getRouteApi,
+  type RegisteredRouter,
+  type RouteIds,
+  useNavigate,
+} from '@tanstack/react-router';
+import { useCallback } from 'react';
+
+import type { PageSearchType } from '@/types/schema/pageSearch';
+
+type RouteTreeIds = RouteIds<RegisteredRouter['routeTree']>;
+
+export const useSearchParams = <T extends RouteTreeIds>(routeId: T) => {
+  const { useSearch } = getRouteApi<T>(routeId);
+  const navigate = useNavigate();
+  const params = useSearch();
+  type Params = typeof params;
+
+  const setParams = useCallback(
+    (props: Partial<Params>) => {
+      return navigate({
+        to: '.',
+        search: (prev: object) => ({ ...prev, ...props }),
+      });
+    },
+    [navigate]
+  );
+  const resetParams = useCallback(
+    () => navigate({ to: '.', search: {}, replace: true }),
+    [navigate]
+  );
+
+  return { params: params as PageSearchType, setParams, resetParams } as const;
+};
+
+export type UseSearchParams<T extends RouteTreeIds> = ReturnType<
+  typeof useSearchParams<T>
+>;
diff --git a/src/utils/useTablePagination.ts b/src/utils/useTablePagination.ts
new file mode 100644
index 0000000..602a510
--- /dev/null
+++ b/src/utils/useTablePagination.ts
@@ -0,0 +1,58 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { TablePaginationConfig } from 'antd';
+import { useCallback, useMemo } from 'react';
+
+import type { FileRoutesByTo } from '@/routeTree.gen';
+import type { APISIXListResponse } from '@/types/schema/apisix/type';
+import { pageSearchSchema } from '@/types/schema/pageSearch';
+
+import type { UseSearchParams } from './useSearchParams';
+
+type ListPageKeys = `${keyof FilterKeys<FileRoutesByTo, 's'>}/`;
+type Props<T> = {
+  data: APISIXListResponse<T>;
+  /** if params is from useSearchParams, refetch is not needed */
+  refetch?: () => void;
+} & Pick<UseSearchParams<ListPageKeys>, 'params' | 'setParams'>;
+
+export const useTablePagination = <T>(props: Props<T>) => {
+  const { data, refetch, setParams } = props;
+  const params = useMemo(
+    () => pageSearchSchema.parse(props.params),
+    [props.params]
+  );
+  const { page, page_size } = params;
+
+  const onChange: TablePaginationConfig['onChange'] = useCallback(
+    (page: number, pageSize: number) => {
+      setParams({ page, page_size: pageSize });
+      refetch?.();
+    },
+    [refetch, setParams]
+  );
+
+  const pagination = {
+    current: page,
+    pageSize: page_size,
+    total: data.total ?? 0,
+    showSizeChanger: true,
+    onChange: onChange,
+  } as TablePaginationConfig;
+  return pagination;
+};