Storyie started with no admin tooling at all. The reasoning was straightforward: it was a solo project, the user base was small, and direct database access via Supabase Studio covered every operational need. That held up for a while.
After six months, as the X (Twitter) auto-posting feature matured and content moderation became real work, "run a SQL query and hope I typed it right" started feeling like a liability. This post is about the design decisions we made when we finally built the admin layer — when to build it, where to put it, how to keep it small, and what we deliberately skipped.
TL;DR
- Don't build an admin dashboard until the same operation has happened manually three or more times.
- Put it inside the existing Next.js app — not a separate service — to share auth, packages, and infra.
- Use an environment-variable ID list for admin authorization; skip RBAC until you have more than one admin.
- Authorize in the layout, not per-page: one Server Component redirect covers every route in the group.
- Use Server Actions for mutations, Server Components for reads. No REST API, no client state management.
- Decide what not to build. Analytics, user management, and audit logs can wait.
Layer | Implementation | Rationale |
|---|---|---|
Auth guard | Async Server Component layout + | One check covers every page; no HTML leaks to client |
Admin ID store |
| No DB round-trip, trivial to replace later |
Mutations | Server Actions in | Type-safe, auth check at top, |
Reads | Server Components with | Always fresh, no client state needed |
Filtering | URL search params | No state library, native browser navigation |
What we were doing before
Before the admin dashboard, operations looked like this:
- Direct SQL in Supabase Studio — updating post statuses, spot-checking user data.
- Local scripts — running batch jobs manually when needed.
- Reading logs and guessing — "this post probably failed, let me check."
This works fine at small scale. The breaking point came from X post management. Once we had five distinct statuses (draft → review → scheduled → posted → failed) and a retry flow for failures, writing UPDATE x_posts SET status = 'draft', error_message = NULL WHERE id = '...' by hand on every retry was both tedious and error-prone.
The rule we landed on: if you have typed the same SQL three or more times by hand, it belongs in a UI.
Design choice: inside the existing app
When the dashboard became inevitable, the first question was where it lives. A separate app (separate deploy, separate domain) versus mounting it inside the existing Next.js app.
For a solo project, the answer was easy:
- No infrastructure overhead. A separate service means another deployment target and another thing that can fall over.
- Auth is free. The existing Supabase session works without any extra configuration.
- The service layer is directly importable. The monorepo packages (
@storyie/x-postingand others) are already available insideapps/web. A separate service would need its own API client.
The layout inside apps/web:
app/(protected)/admin/
├── layout.tsx # admin auth guard
└── x-posting/
├── page.tsx # post list
├── components.tsx # UI components
└── actions.ts # Server ActionsThis is just a Next.js Route Group. No new build configuration, no new deployment, no new domain to manage.
Admin authorization: environment variables over RBAC
We have an RBAC package in the codebase. We chose not to use it for admin access.
// lib/admin.ts
export function isAdmin(userId: string): boolean {
const adminIds = process.env.ADMIN_USER_IDS?.split(",") ?? [];
return adminIds.includes(userId.trim());
}Three reasons this is the right call right now:
- One admin. Adding a person to a role table and querying it on every page load adds a database round-trip for exactly one row that never changes.
- Resolves at deploy time. The environment variable is baked in when the app starts. No database dependency, no seeding step in fresh environments.
- Easy to replace. When a second admin exists and RBAC makes sense, the change is
isAdmin()→ database query. Every call site stays the same.
Authorization in the layout
The guard runs once, in the layout, and covers every admin page automatically:
// admin/layout.tsx
export default async function AdminLayout({ children }) {
const user = await getCachedUser();
if (!user || !isAdmin(user.id)) {
redirect("/");
}
return <>{children}</>;
}One thing worth noting: because this redirect happens inside a Server Component, the HTML for the admin pages is never sent to an unauthorized client. A client-side check (checking isAdmin in a useEffect, for example) would render the page first and redirect second — which means the markup is briefly visible and included in the initial payload. The layout approach avoids both problems, and it composes naturally with Route Groups because the layout automatically wraps every page underneath it.
Middleware is a valid alternative, but we found it created more indirection without adding anything for this case.
Server Actions for mutations
The most common admin antipattern is reaching for REST API endpoints. For an internal tool with one user, that's pure overhead.
Server Actions call the service layer directly:
// admin/x-posting/actions.ts
"use server";
export async function changePostStatus(
id: string,
newStatus: "scheduled" | "archived",
scheduledAt?: string
) {
const { user } = await getAuthenticatedUser();
if (!isAdmin(user.id)) {
throw new Error("Unauthorized");
}
if (newStatus === "scheduled") {
if (!scheduledAt) throw new Error("scheduledAt is required");
const post = await postService.getPost(id);
if (post?.status === "failed") {
// reset before rescheduling
await xPostQueries.updatePost(id, {
status: "draft",
errorMessage: null,
});
}
await postService.schedulePost(id, new Date(scheduledAt));
} else if (newStatus === "archived") {
await xPostQueries.updatePost(id, { status: "archived" });
}
revalidatePath("/admin/x-posting");
}The authorization check is at the top of the function — not hidden in middleware configuration. TypeScript covers the argument and return types end to end. revalidatePath handles cache invalidation so the list updates immediately after the action. No state management library, no manual refetch.
Server Components for reads
List pages in the admin are simple: fetch data on the server, render a table. No client-side state needed.
async function PostsTable({ status }: { status?: XPostStatusType }) {
const posts = await postService.listPosts({ status, limit: 100 });
return (
<table className="w-full text-sm">
<thead>...</thead>
<tbody>
{posts.map((post) => (
<tr key={post.id}>
<td>{truncate(post.content)}</td>
<td><StatusBadge status={post.status} /></td>
<td><StatusActions postId={post.id} status={post.status} /></td>
</tr>
))}
</tbody>
</table>
);
}We set force-dynamic on the admin pages so they always fetch fresh data — admin tooling with stale reads is worse than no admin tooling. Filtering by status is done through URL search params so you can link directly to a filtered view and the browser back button works normally.
What we didn't build
This list is as important as the implementation above.
No analytics dashboard. User counts, traffic, and engagement are already visible in Supabase Studio and our analytics tooling. Building a duplicate inside the admin saves no time and adds maintenance overhead.
No user management UI. Supabase handles user listing and search out of the box. If we need a ban flow or role assignment, we'll build it when the need is real — not in advance.
No generic CRUD generator. Tools like Retool and AdminJS have genuine value at scale, but they come with setup cost and opinions that sometimes conflict with a custom service layer. For one or two managed entities, a hand-written page takes an hour and does exactly what's needed.
No audit log. When the admin is one person, the question "who did this?" has one answer. Audit logging becomes important the moment a second person has write access. That moment hasn't arrived.
The discipline here is harder than it sounds. Each of these feels justified on its own. The test we applied: "would building this change how I operate today?" If the answer was no, we skipped it.
Lessons from running this in production
Admin dashboards are a step toward automation, not an end state
The real goal for X post management is full automation: AI generates post content, the scheduler picks a time, failures retry automatically. The admin dashboard exists for operations where the automation rules aren't stable enough yet. When a new edge case appears, we handle it manually via the dashboard, observe the pattern, and encode it into the automation once it's consistent. The dashboard is a staging ground for rules that aren't ready to be rules yet.
Keeping the service layer in shared packages pays off here too
When we built the admin dashboard, there was nothing new to write for data access. @storyie/x-posting's service layer already handled the operations; the admin just needed to call it. If admin-specific queries had been mixed into the app layer instead of the shared package, we would have had to extract them or duplicate them. The monorepo architecture that keeps services in packages rather than apps pays dividends in exactly this scenario.
Server Actions + Server Components is the right default for internal tools
SPAs are built for interactive applications with complex client state. Admin dashboards for one person are not that. Every page load in the admin fetches fresh server data. Every mutation calls a Server Action and revalidates. There is no optimistic update, no cache synchronization problem, no stale data risk from client-side caching. The full-page reload on navigation is not a user experience problem when the user is you.
Takeaways
- Wait for the third repetition. Build the UI when the same operation has happened three times manually. Not before.
- Put it in the existing app. The auth, the packages, and the infra are already there. Don't spin up a new service until you outgrow the single-app model.
- Env var for admin IDs, not RBAC. One person, one env var, zero database round-trips. Replace it when you have a second admin.
- Authorize in the layout. One Server Component redirect protects every page in the group and keeps admin HTML off unauthorized clients.
- Server Actions + Server Components. No REST API, no client state library, no optimistic updates.
revalidatePathhandles invalidation. - Build the list of skips explicitly. Every feature you don't build is one less thing to maintain. Audit the list.
Related Posts
- Building a Monorepo with pnpm and TypeScript — the shared package conventions that make the service layer reusable
- Cross-platform Lexical with
use dom— another example of where the monorepo boundary decisions paid off - Next.js 16 Deployment with SST — the deployment setup the admin dashboard lives inside
Try Storyie
Storyie is the diary app that prompted all of this. Write a private entry at storyie.com or on the iOS app. The admin dashboard described here is what keeps the automated features running reliably behind the scenes.