<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hadi Khalil</title>
    <description>The latest articles on DEV Community by Hadi Khalil (@hadi_khalil).</description>
    <link>https://dev.to/hadi_khalil</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4089975%2F601c193e-aaaa-4a3d-9a1c-5380ba159231.png</url>
      <title>DEV Community: Hadi Khalil</title>
      <link>https://dev.to/hadi_khalil</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hadi_khalil"/>
    <language>en</language>
    <item>
      <title>How I built a 17-page admin panel with Next.js 16 (and the bugs I fixed along the way)</title>
      <dc:creator>Hadi Khalil</dc:creator>
      <pubDate>Sat, 22 Aug 2026 17:01:05 +0000</pubDate>
      <link>https://dev.to/hadi_khalil/how-i-built-a-17-page-admin-panel-with-nextjs-16-and-the-bugs-i-fixed-along-the-way-3433</link>
      <guid>https://dev.to/hadi_khalil/how-i-built-a-17-page-admin-panel-with-nextjs-16-and-the-bugs-i-fixed-along-the-way-3433</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The goal: edit everything without code&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;The answer: a full 17-page admin dashboard with live CRUD.&lt;/p&gt;

&lt;p&gt;The 17 pages&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Dashboard — KPIs, charts, reviews feed&lt;/li&gt;
&lt;li&gt;Screenshots — CRUD with image upload&lt;/li&gt;
&lt;li&gt;Features — manage feature cards&lt;/li&gt;
&lt;li&gt;How It Works — 3-step section editor&lt;/li&gt;
&lt;li&gt;Reviews — ratings + platform + replies&lt;/li&gt;
&lt;li&gt;Pricing — 3 tiers (monthly/yearly toggle)&lt;/li&gt;
&lt;li&gt;Press &amp;amp; Awards — media logos&lt;/li&gt;
&lt;li&gt;Comparison — feature comparison table&lt;/li&gt;
&lt;li&gt;FAQ — searchable accordion&lt;/li&gt;
&lt;li&gt;Footer — columns + links + social&lt;/li&gt;
&lt;li&gt;Theme — colors + 5 fonts + radius (LIVE!)&lt;/li&gt;
&lt;li&gt;Blog — markdown editor + draft/publish&lt;/li&gt;
&lt;li&gt;Messages — contact form inbox&lt;/li&gt;
&lt;li&gt;Audit Log — every admin action tracked&lt;/li&gt;
&lt;li&gt;Settings — app identity + store links&lt;/li&gt;
&lt;li&gt;Account — change password&lt;/li&gt;
&lt;li&gt;Login — secure sign-in&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Architecture: how edits flow to the live site&lt;/p&gt;

&lt;p&gt;Here's the flow when an admin clicks "Save":&lt;/p&gt;

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

&lt;p&gt;This means the admin sees instant feedback, and the live site updates without a reload.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu7gtpgj0uwoq2i24mtki.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu7gtpgj0uwoq2i24mtki.png" alt=" " width="800" height="450"&gt;&lt;/a&gt; Bug 1: Tailwind CSS 4 font inlining&lt;/p&gt;

&lt;p&gt;The most devious bug I hit: changing the font in the admin didn't reflect on the live site.&lt;/p&gt;

&lt;p&gt;The cause: In globals.css, --font-sans was declared inside &lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt; inline:&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt; inline {&lt;br&gt;
  --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;When Tailwind 4 sees a var() value in &lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt;, it inlines the value into the generated utility class instead of referencing the variable by name. So the compiled CSS was:&lt;/p&gt;

&lt;p&gt;.font-sans { font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif; }&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix: Move --font-sans outside &lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt; inline into a plain :root block, then add the utilities manually so they reference the variable by name (dynamic):&lt;/p&gt;

&lt;p&gt;:root {&lt;br&gt;
  --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;@layer base {&lt;br&gt;
  .font-sans { font-family: var(--font-sans); }&lt;br&gt;
  .font-mono { font-family: var(--font-mono); }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Now .font-sans references var(--font-sans) by name — runtime overrides work.&lt;/p&gt;

&lt;p&gt;Bug 2: --font-sans scope vs next/font&lt;/p&gt;

&lt;p&gt;Even with the fix above, the font still didn't switch. The second cause was a scope mismatch.&lt;/p&gt;

&lt;p&gt;In theme.ts, generateThemeCss() was emitting:&lt;/p&gt;

&lt;p&gt;:root{--font-sans:var(--font-poppins), ...;}&lt;/p&gt;

&lt;p&gt;But next/font/google declares the per-family variables on body:&lt;/p&gt;

&lt;p&gt;body. { --font-poppins: '_&lt;em&gt;Poppins&lt;/em&gt;', ...; }&lt;/p&gt;

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

&lt;p&gt;The fix: Target :root, body in generateThemeCss:&lt;/p&gt;

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

&lt;p&gt;Now the override reaches body, where --font-poppins is defined.&lt;/p&gt;

&lt;p&gt;Security: not just a CRUD app&lt;/p&gt;

&lt;p&gt;I didn't want a basic admin panel with zero security. Here's what's built in:&lt;/p&gt;

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

&lt;p&gt;Try it yourself&lt;/p&gt;

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

&lt;p&gt;Live demo: &lt;a href="https://nexus-by-hadi-kits.vercel.app/" rel="noopener noreferrer"&gt;https://nexus-by-hadi-kits.vercel.app/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click "Continue as demo admin" and edit anything. Your changes are saved to your browser only.&lt;/p&gt;

&lt;p&gt;Full template&lt;/p&gt;

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

&lt;p&gt;Buy:  &lt;a href="https://www.getly.store/product/nexus-next-js-16-mobile-app-landing-starter-kit-with-admin-panel" rel="noopener noreferrer"&gt;https://www.getly.store/product/nexus-next-js-16-mobile-app-landing-starter-kit-with-admin-panel&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I write daily about this build on Indie Hackers. Follow along: &lt;a href="https://www.indiehackers.com/post/i-just-launched-my-first-next-js-16-starter-kit-after-months-of-work-nexus-eea2fd8f41" rel="noopener noreferrer"&gt;https://www.indiehackers.com/post/i-just-launched-my-first-next-js-16-starter-kit-after-months-of-work-nexus-eea2fd8f41&lt;/a&gt;&lt;br&gt;
—&lt;/p&gt;

&lt;p&gt;Note: All demo data (downloads, active users, ratings, top countries) is AI-generated for demonstration purposes only.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>typescript</category>
      <category>tailwindcss</category>
    </item>
  </channel>
</rss>
