DEV Community

Cover image for How I built a 17-page admin panel with Next.js 16 (and the bugs I fixed along the way)
Hadi Khalil
Hadi Khalil

Posted on

How I built a 17-page admin panel with Next.js 16 (and the bugs I fixed along the way)

After months of work, I launched Nexus — a Next.js 16 starter kit with a full admin dashboard. Today I'll walk through how the 17-page admin panel works, the architecture decisions, and two sneaky bugs I had to fix.

The goal: edit everything without code

The hardest part of building a landing page template isn't the landing page itself — it's making every word, image, and price editable WITHOUT touching code.

When I started, the question was: "How do I let the buyer change anything, anytime, without opening an IDE or triggering a deploy?"

The answer: a full 17-page admin dashboard with live CRUD.

The 17 pages

  1. Dashboard — KPIs, charts, reviews feed
  2. Screenshots — CRUD with image upload
  3. Features — manage feature cards
  4. How It Works — 3-step section editor
  5. Reviews — ratings + platform + replies
  6. Pricing — 3 tiers (monthly/yearly toggle)
  7. Press & Awards — media logos
  8. Comparison — feature comparison table
  9. FAQ — searchable accordion
  10. Footer — columns + links + social
  11. Theme — colors + 5 fonts + radius (LIVE!)
  12. Blog — markdown editor + draft/publish
  13. Messages — contact form inbox
  14. Audit Log — every admin action tracked
  15. Settings — app identity + store links
  16. Account — change password
  17. Login — secure sign-in

Architecture: how edits flow to the live site

Here's the flow when an admin clicks "Save":

  1. Admin form calls save(nextContent) from useStore()
  2. useStore does an optimistic update — updates the local cache immediately and publishes a CONTENT_EVENT
  3. All components using useSyncExternalStore re-render with the new content
  4. In parallel, a PUT /api/admin/content saves to the database
  5. If the save fails, the optimistic update is reverted and a toast shows the error

This means the admin sees instant feedback, and the live site updates without a reload.

 Bug 1: Tailwind CSS 4 font inlining

The most devious bug I hit: changing the font in the admin didn't reflect on the live site.

The cause: In globals.css, --font-sans was declared inside @theme inline:

@theme inline {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}

When Tailwind 4 sees a var() value in @theme, it inlines the value into the generated utility class instead of referencing the variable by name. So the compiled CSS was:

.font-sans { font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif; }

Notice: .font-sans references var(--font-inter) directly — hardcoded to Inter. Updating --font-sans at runtime had no effect because the utility class didn't look at it.

The fix: Move --font-sans outside @theme inline into a plain :root block, then add the utilities manually so they reference the variable by name (dynamic):

:root {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}

@layer base {
.font-sans { font-family: var(--font-sans); }
.font-mono { font-family: var(--font-mono); }
}

Now .font-sans references var(--font-sans) by name — runtime overrides work.

Bug 2: --font-sans scope vs next/font

Even with the fix above, the font still didn't switch. The second cause was a scope mismatch.

In theme.ts, generateThemeCss() was emitting:

:root{--font-sans:var(--font-poppins), ...;}

But next/font/google declares the per-family variables on body:

body. { --font-poppins: '_Poppins', ...; }

So var(--font-poppins) was undefined in :root's scope — the lookup failed silently and the font fell back to the system stack.

The fix: Target :root, body in generateThemeCss:

:root{...colors/radius...;}:root,body{--font-sans:${fs};}.dark{...};

Now the override reaches body, where --font-poppins is defined.

Security: not just a CRUD app

I didn't want a basic admin panel with zero security. Here's what's built in:

  • JWT auth with httpOnly cookies (24h expiry)
  • bcrypt (cost factor 12) for password hashing
  • Session versioning — changing the password bumps a DB counter, invalidating all existing JWTs
  • CSRF protection — same-origin check on every state-changing route
  • Rate limiting — 5 login attempts / 15 min per IP (sliding window)
  • Magic-bytes image upload — validates file signature, not just MIME type
  • Audit log — every create/update/delete is recorded with the actor + JSON snapshot
  • Honeypot — hidden fields on contact + newsletter forms catch bots silently

Try it yourself

I set up a live demo where every visitor gets their own browser sandbox (per-visitor localStorage). Try the admin panel — no signup required:

Live demo: https://nexus-by-hadi-kits.vercel.app/

Click "Continue as demo admin" and edit anything. Your changes are saved to your browser only.

Full template

The full source code (TypeScript), Prisma schema, Dockerfile, GitHub Actions CI, and 65KB of documentation is available for $89 (launch offer, regularly $149).

Buy: https://www.getly.store/product/nexus-next-js-16-mobile-app-landing-starter-kit-with-admin-panel

I write daily about this build on Indie Hackers. Follow along: https://www.indiehackers.com/post/i-just-launched-my-first-next-js-16-starter-kit-after-months-of-work-nexus-eea2fd8f41

Note: All demo data (downloads, active users, ratings, top countries) is AI-generated for demonstration purposes only.

Top comments (0)