gw-admin
v0.2.3
Published
Composable administration UI for the Next.js App Router and next-headless-crud.
Maintainers
Readme
gw-admin
Composable administration UI for the Next.js App Router and
next-headless-crud.
gw-admin supplies reusable UI without owning application routes,
authentication, database access, navigation data, brand assets, file storage,
or deployment. Every feature is available through a focused package subpath,
so an application can use the complete default UI, replace one page, or use
only a few primitives.
Design goals
- Application-owned
app/admin/**routes - Replaceable desktop, mobile, page-header, and page-content slots
- Separate imports for CRUD pages, CRUD forms, attachments, rich text, and account actions
- Service-owned authentication, Server Actions, repositories, and uploaders
- Optional default CSS with stable
gw-admin-*class names and CSS variables - Preserved React Server Component and Client Component boundaries
Requirements
- Node.js 20 or newer
- React 19 or newer
- Next.js 15 or newer when using the supplied Next.js components
Feature entry points have additional optional peers:
| Entry point | Additional packages |
| --- | --- |
| gw-admin/crud/pages | next-headless-crud |
| gw-admin/crud/form | next-headless-crud, gw-store, react-store-input |
| gw-admin/crud/preset | Complete CRUD peer set below |
| gw-admin/crud/preset/client | Complete CRUD peer set below |
| gw-admin/attachments | gw-store, gw-react-file-input |
| gw-admin/rich-text | gw-store, react-store-input, gw-rich-text-editor |
The current integration baseline is next-headless-crud 0.1.1, gw-store
0.2, react-store-input 0.7, and gw-rich-text-editor 0.2.
Install
Install the core package:
npm install gw-adminInstall only the peers required by the features the application uses. For the complete CRUD, attachment, and editor stack:
npm install next-headless-crud gw-store react-store-input \
gw-react-file-input gw-rich-text-editorThe package ships compiled JavaScript and declarations. A consuming Next.js
application does not need transpilePackages.
Recommended setup
The preset API keeps the normal application integration to three small files. The application supplies only its brand and navigation, a storage uploader and resolver, and its CRUD resources and Server Actions.
1. Layout
// app/admin/layout.tsx
import Image from "next/image";
import { AdminLayout } from "gw-admin";
import "gw-admin/styles.css";
import "gw-admin/rich-text.css";
const navigation = [
{ label: "홈", href: "/admin" },
{ label: "새소식", href: "/admin/news" },
{ label: "계정", href: "/admin/account" },
] as const;
export default async function Layout({ children }: { children: React.ReactNode }) {
await requireAdmin();
return (
<AdminLayout
brand={<Image src="/logo.png" alt="서비스" width={100} height={100} />}
navigation={navigation}
>
{children}
</AdminLayout>
);
}2. One client-side form preset
// admin/admin-form.tsx
"use client";
import { createAdminCrudForm } from "gw-admin/crud/preset/client";
import { uploadFile } from "@/files/upload-file";
export const AdminForm = createAdminCrudForm({
upload: uploadFile,
hiddenFields: ["slug", "summary", "publishedAt"],
hiddenFieldsByPath: {
"/admin/inquiries": ["status", "repliedAt"],
},
createDefaults: { isPrivate: true },
});By convention the preset renders attachmentFileIds as an attachment field
and content and replyContent as rich-text fields. Every name and behavior
can be changed through the preset options.
3. One catch-all CRUD route
// app/admin/[...path]/page.tsx
import {
createAdminCrudPages,
createAdminCrudRoutePage,
} from "gw-admin/crud/preset";
import { AdminForm } from "@/admin/admin-form";
import { resolveFiles } from "@/files/resolve-files";
const pages = createAdminCrudPages({
form: AdminForm,
resolveFiles,
});
export default createAdminCrudRoutePage({
pathPrefix: "admin",
pages,
routes: [
{
resource: newsResource,
actions: { create: createNews, update: updateNews, delete: deleteNews },
},
{
resource: inquiriesResource,
actions: {
create: createInquiry,
update: updateInquiry,
delete: deleteInquiry,
},
},
],
});uploadFile and resolveFiles use the small storage-neutral types exported by
gw-admin/attachments; the package does not require a particular API, CDN, or
database.
The preset is optional. Low-level entry points remain public for applications that need only one component or want to own the page composition.
To replace one page for one resource, add only that override to the route:
{
resource: reportsResource,
actions: reportActions,
pages: { detail: ReportsDetailPage },
}To opt out at the field level, use renderField in createAdminCrudForm or
renderDetailField in createAdminCrudPages. Returning undefined delegates
back to the preset.
Entry points
| Import | Purpose |
| --- | --- |
| gw-admin | Server-safe shell, sidebar, navigation, and page header |
| gw-admin/layout | Shell and desktop sidebar |
| gw-admin/layout/mobile | Optional client-side mobile header and drawer |
| gw-admin/header | Breadcrumb page header |
| gw-admin/form | Application-owned form field, field-group, and alert primitives |
| gw-admin/navigation | Application-owned navigation rendering |
| gw-admin/account | Configurable logout button |
| gw-admin/pages | Dashboard and account pages |
| gw-admin/crud/pages | List, detail, create, update, and delete UI |
| gw-admin/crud/form | Store-backed standard CRUD form |
| gw-admin/crud/preset | Shared CRUD pages and catch-all route factory |
| gw-admin/crud/preset/client | Client-side form preset factory |
| gw-admin/attachments | Attachment list and uploader field |
| gw-admin/rich-text | Store-backed editor, view, nodes, and upload adapter |
| gw-admin/styles.css | Optional admin UI theme |
| gw-admin/rich-text.css | Optional rich-editor behavioral baseline |
Application-owned forms
Use the lower-level form primitives when a resource does not fit a flat CRUD model, such as an editor that owns nested rows in a separate table:
import { AdminFormAlert, AdminFormField, AdminFormFields } from "gw-admin/form";
<AdminFormFields>
{message && <AdminFormAlert tone="error">{message}</AdminFormAlert>}
<AdminFormField label="제목" htmlFor="title" required>
<input id="title" className="gw-admin-input" />
</AdminFormField>
</AdminFormFields>;AdminCrudForm uses these same primitives internally. CRUD list pages also
accept breadcrumbs, headerActions, and renderRowActions for
resource-specific page composition.
Import focused subpaths in shared libraries and large applications. This keeps optional feature dependencies outside bundles that do not use them.
Application-owned layout
The application chooses its own brand and navigation.
// app/admin/layout.tsx
import Image from "next/image";
import {
AdminNavigation,
AdminShell,
AdminSidebar,
type AdminNavigationItem,
} from "gw-admin";
import { AdminMobileHeader } from "gw-admin/layout/mobile";
import "gw-admin/styles.css";
const navigation: readonly AdminNavigationItem[] = [
{ label: "홈", href: "/admin" },
{ label: "새소식", href: "/admin/news" },
{ label: "계정", href: "/admin/account" },
];
const brand = <Image src="/logo.png" alt="서비스 이름" width={100} height={100} />;
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await requireAdmin();
return (
<AdminShell
sidebar={
<AdminSidebar brand={brand}>
<AdminNavigation items={navigation} />
</AdminSidebar>
}
mobileHeader={
<AdminMobileHeader brand={brand} navigation={navigation} />
}
>
{children}
</AdminShell>
);
}AdminShell does not create either navigation surface by itself:
- Pass no
sidebarto omit the desktop sidebar. - Pass no
mobileHeaderto omit the mobile header. - Pass an application component into either slot to replace the default.
- Skip
AdminShellentirely and import onlyAdminPageHeaderor a CRUD page.
Authentication must remain in the application layout or page. Rendering an admin component is not an authorization check.
Logout
The default HTTP flow is configurable and performs a full navigation after success so cached authenticated UI is discarded:
import { AdminLogoutButton } from "gw-admin/account";
<AdminLogoutButton
endpoint="/api/auth/logout"
redirectTo="/login"
/>;For a non-HTTP client flow, wrap the button in an application Client Component
and supply request:
"use client";
import { AdminLogoutButton } from "gw-admin/account";
import { authClient } from "@/auth/client";
export function ServiceLogoutButton() {
return (
<AdminLogoutButton
request={() => authClient.logout()}
redirectTo="/sign-in"
/>
);
}CRUD pages
The package builds on next-headless-crud; resources and Server Actions still
belong to the application.
// app/admin/[...path]/page.tsx
import { createCrudRoutePage, defineCrudRoute } from "next-headless-crud/next";
import {
AdminCrudCreatePage,
AdminCrudDetailPage,
AdminCrudListPage,
AdminCrudUpdatePage,
} from "gw-admin/crud/pages";
import { newsResource } from "@/admin/news/resource";
import {
createNews,
deleteNews,
updateNews,
} from "@/admin/news/actions";
const newsRoute = defineCrudRoute({
resource: newsResource,
actions: {
create: createNews,
update: updateNews,
delete: deleteNews,
},
pages: {
list: AdminCrudListPage,
create: AdminCrudCreatePage,
detail: AdminCrudDetailPage,
update: AdminCrudUpdatePage,
},
});
export default createCrudRoutePage([newsRoute]);Per-page opt-out
Next.js gives an explicit page precedence over a catch-all route. A custom page can therefore replace one package page without changing the remaining CRUD routes:
app/admin/[...path]/page.tsx -> shared CRUD pages
app/admin/account/page.tsx -> application-owned page
app/admin/reports/page.tsx -> application-owned pageRoutes can also mix page implementations in one resource:
pages: {
list: AdminCrudListPage,
create: ServiceCreatePage,
detail: AdminCrudDetailPage,
update: ServiceUpdatePage,
}Detail field opt-out
Hide fields or replace rendering one field at a time:
import {
AdminCrudDetailPage,
type AdminCrudDetailPageProps,
} from "gw-admin/crud/pages";
import { AdminRichTextView } from "gw-admin/rich-text";
export function NewsDetailPage(props: AdminCrudDetailPageProps) {
return (
<AdminCrudDetailPage
{...props}
hiddenFields={["description"]}
longTextFields={["content"]}
renderField={({ field, value }) => {
if (field.name === "content" && typeof value === "string") {
return <AdminRichTextView value={value} />;
}
return undefined;
}}
/>
);
}renderActions can replace every header action, while formatValue can
replace scalar formatting across the page.
Store-backed form
AdminCrudForm handles standard input, select, checkbox, and textarea fields.
It deliberately does not guess that a field name represents an attachment,
editor, derived value, or server-managed value.
"use client";
import {
AdminCrudForm,
type AdminCrudFormProps,
} from "gw-admin/crud/form";
export function ServiceAdminForm(props: AdminCrudFormProps) {
return (
<AdminCrudForm
{...props}
fieldModes={{
description: "textarea",
slug: "hidden",
publishedAt: "hidden",
}}
/>
);
}The save action is a type="button" action. Pressing Enter in a large input
screen does not implicitly submit the form.
Composing attachment and editor fields
Feature adapters are created in the consuming application, not in the package. This example is a Client Component, so uploader functions never cross the Server Component serialization boundary:
"use client";
import {
AdminCrudForm,
type AdminCrudFormProps,
} from "gw-admin/crud/form";
import {
AdminFileAttachments,
type AdminFileUploader,
type AdminUploadedFile,
} from "gw-admin/attachments";
import {
AdminRichTextEditor,
adminRichTextNodes,
createAdminRichTextUploadAdapter,
} from "gw-admin/rich-text";
import "gw-admin/rich-text.css";
const uploadFile: AdminFileUploader = async (file, context) => {
const uploaded = await serviceFileUploader(file, context);
return {
id: uploaded.id,
name: uploaded.name,
type: uploaded.type,
size: uploaded.size,
key: uploaded.key,
url: cdn(uploaded.key),
metadata: uploaded.metadata,
};
};
const editorUpload = createAdminRichTextUploadAdapter(uploadFile);
export function ServiceAdminForm(
props: AdminCrudFormProps & {
attachments: readonly AdminUploadedFile[];
},
) {
return (
<AdminCrudForm
{...props}
fieldModes={{ slug: "hidden" }}
renderField={({ field, store, error, errorId, setUploading }) => {
if (field.name === "attachmentFileIds") {
return (
<AdminFileAttachments
store={store}
name={field.name}
uploader={uploadFile}
initialFiles={props.attachments}
error={error}
onUploadingChange={setUploading}
/>
);
}
if (field.name === "content") {
return (
<AdminRichTextEditor
store={store}
name={field.name}
upload={editorUpload}
nodes={adminRichTextNodes}
invalid={Boolean(error)}
describedBy={error ? errorId : undefined}
/>
);
}
return undefined;
}}
/>
);
}The rich-text adapter only requires the cancellation and progress context, so
the same AdminFileUploader can be reused for attachment and editor fields.
An application that does not need uploads simply does not install or import the
attachment entry point. Omitting upload from AdminRichTextEditor also
removes the attachment toolbar action.
Styling
Default theme:
import "gw-admin/styles.css";Editor behavioral baseline, only when the rich-text entry point is used:
import "gw-admin/rich-text.css";The package never imports these files automatically. To own every style, omit
both imports and target the stable gw-admin-* class names yourself.
The default theme can also be adjusted without replacing selectors:
:root {
--gw-admin-background: #fafafa;
--gw-admin-surface: #ffffff;
--gw-admin-text: #111111;
--gw-admin-primary: #0b4d3b;
--gw-admin-primary-text: #ffffff;
--gw-admin-danger: #a61b1b;
--gw-admin-border: #d8d8d8;
--gw-admin-sidebar-width: 17rem;
--gw-admin-content-width: 90rem;
--gw-admin-radius: 0;
}Server and client boundaries
- Layout shell, sidebar, navigation, header, list page, detail page, and page loaders are server-compatible.
- Mobile navigation, logout, delete, CRUD form, attachment input, and editor are Client Components.
- Application upload functions must be declared in an application Client Component before they are passed to attachment or editor components.
- Authentication, authorization, resource scoping, and Server Actions must validate permissions independently of the rendered UI.
Publishing and updates
The repository includes a manual-compatible npm publishing workflow. Configure
NPM_PUBLISH_TOKEN in the GitHub repository, bump the package version, and
push the package metadata to main.
npm version patch
git push --follow-tagsBefore publishing:
npm run check
npm run pack:checkConsuming services update manually:
npm install [email protected]No automatic pull request or consumer deployment is performed.
