Does a solo project need an admin dashboard? Building minimal internal tools with Next.js

Storyie Engineering Team
8 min read

How we decided when to build an admin dashboard for Storyie, how we implemented it inside our existing Next.js app with minimal overhead, and — equally important — what we deliberately chose not to build.

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 + isAdmin()

One check covers every page; no HTML leaks to client

Admin ID store

ADMIN_USER_IDS env var

No DB round-trip, trivial to replace later

Mutations

Server Actions in actions.ts

Type-safe, auth check at top, revalidatePath built in

Reads

Server Components with force-dynamic

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:

  1. Direct SQL in Supabase Studio — updating post statuses, spot-checking user data.
  2. Local scripts — running batch jobs manually when needed.
  3. 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-posting and others) are already available inside apps/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 Actions

This 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:

  1. 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.
  2. Resolves at deploy time. The environment variable is baked in when the app starts. No database dependency, no seeding step in fresh environments.
  3. 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. revalidatePath handles invalidation.
  • Build the list of skips explicitly. Every feature you don't build is one less thing to maintain. Audit the list.

Related Posts

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.