Skip to content

Custom admin pages

To build a screen, see Add a custom admin page.

Custom admin pages let an OpenShop app add embedded screens without forking the framework UI. The API is experimental and must be enabled explicitly.

openshop.config.ts
export default app.defineConfig({
// ...
experimental: {
customPages: {
navigation: [{ label: 'Reviews', path: '/reviews' }],
},
},
})

Navigation entries are appended after built-in links. Each path must resolve to a discovered static page; dynamic detail pages remain routable without appearing in navigation.

Create admin/pages/<route>/page.tsx. Nested folders create nested routes and [name] folders create dynamic parameters.

admin/pages/reviews/page.tsx
import { defineAdminPage, useLoader } from 'openshop/admin'
import { listReviews } from './actions.server.ts'
function Reviews() {
const result = useLoader(listReviews, undefined)
return (
<s-page heading="Reviews">
{result.loading && <s-spinner accessibilityLabel="Loading reviews" />}
{result.error && <s-banner tone="critical">{result.error.message}</s-banner>}
</s-page>
)
}
export default defineAdminPage({ title: 'Reviews', component: Reviews })

Pages use Preact and Shopify’s Polaris web components. Browser-safe dependencies declared by the app may be imported. Page modules cannot import server-only modules such as #db/*, #engine/*, #shopify/*, #server/*, the openshop root entry point, Node built-ins, or other *.server.ts files. Import page-local actions.server.ts functions instead; OpenShop rewrites those imports to browser stubs. OpenShop emits lazy route chunks and shares the Preact runtime.

Put page-local server functions in actions.server.ts. Imports from that file are rewritten to typed browser stubs; the server implementation is never bundled into the browser.

import { type } from 'arktype'
import { defineAdminAction, defineAdminLoader } from 'openshop/admin'
export const listReviews = defineAdminLoader({
handler: async ({ db, shop, shopifyApp }) => {
// Always filter raw Drizzle queries by trusted shop/app identity.
return { reviews: [] }
},
})
export const saveReview = defineAdminAction({
input: type({ title: 'string > 0' }),
handler: async ({ actor }, input) => ({
ok: true,
title: input.title,
actorId: actor.id,
}),
})

useLoader loads on mount and when its JSON input changes. It discards stale responses and exposes revalidate(). useAction never retries automatically, blocks duplicate calls by default, and can explicitly revalidate loaders:

await save.invoke(input, { revalidate: [listReviews] })

Both use an internal POST /api/pages/custom/* RPC transport authenticated with a fresh Shopify App Bridge session token. Endpoint URLs are not a public API.

Export pageAccess to filter navigation and guard direct page visits. Add an authorize callback to each loader or action that needs a narrower policy. OpenShop evaluates the page’s policy, every existing parent page’s policy, and the function’s own policy on the server. All policies must allow the request.

import { defineAdminPageAccess } from 'openshop/admin'
export const pageAccess = defineAdminPageAccess(({ actor }) => actor.id !== '')

Throw AdminPublicError for failures that are safe to show. Unexpected errors are logged with a request ID and returned without a stack trace or secret data. Structured logs include identity, page/function IDs, duration, and status, but not request or response payloads.

The server context exposes trusted shop, shopifyApp, normalized actor, route params, raw db, a Shopify client, configured connectors, and request metadata. Raw Drizzle access is not automatically tenant-scoped: every app query must filter by the trusted shop and app handle where applicable.

Custom actions are synchronous and use the hosting platform’s request limits. Use an ordinary server route or an OpenShop flow for long-running or retryable work.

openshop/test exports createAdminFunctionTestContext and invokeAdminFunction for handler tests. Keep HTTP integration tests for session JWT validation, policies, route params, and tenant isolation.