Blog

Architecting My Developer Portfolio with Next.js & Supabase: Production Decisions, Security Bugs, and Lessons Learned

Inside Ahsan DevHub: the Next.js and Supabase architecture behind my portfolio, admin dashboard, newsletter, and social sync—plus the security decisions, stale pages, and UI bugs that shaped it.

Architecting My Developer Portfolio with Next.js & Supabase: Production Decisions, Security Bugs, and Lessons Learned

A portfolio becomes an engineering problem when it starts managing real content, permissions, and user data.

Ahsan DevHub grew from a showcase into a small publishing and operations system: a database-backed portfolio, an authenticated admin dashboard, a blog, social synchronization, newsletter subscriptions, and a lightweight CRM.

The most useful lessons came from the boundaries between those pieces. An admin page rendered content but returned HTTP 500. Database changes failed to appear on public pages. Social images worked initially, then expired. A newsletter helper needed a different module boundary to protect double opt-in.

This case study explains the decisions behind the system, the problems uncovered during development and testing, and what I would improve next.

Why Next.js App Router fit the project

The public website and the admin dashboard have different jobs. Articles and project case studies are primarily read experiences. The dashboard needs forms, previews, uploads, and immediate feedback.

Next.js App Router gave me a way to keep both in one application while separating their layouts and rendering responsibilities. The project uses Next.js 16, React, TypeScript, Tailwind CSS, and shadcn/ui. Server-rendered content, route-level metadata, loading states, and dynamic article routes fit the publishing side; Client Components handle interactive controls.

The data layer sits behind query functions in lib/portfolio/data.ts, rather than spreading Supabase queries throughout page components. React's cache() handles request memoization there; public-page freshness is a separate concern handled through revalidation.

That distinction became important later. A successful database write and an updated public page are two different events.

Why Supabase instead of a separate custom backend

The project needed relational content, authentication, image storage, and access policies. Supabase brought PostgreSQL, Auth, and Storage together without requiring me to deploy another backend service just to connect those pieces.

The tradeoff is responsibility: I still own schema design, permission rules, validation, and the behavior of privileged operations. A managed backend does not make those decisions for me.

Development uses local Supabase through the CLI and Docker, with SQL migrations that can be applied to the hosted project. This gives schema changes a versioned history and makes local testing possible before deployment.

The application has three important data paths:

  • Public pages read published content through the data layer.
  • Admin content changes use the signed-in user's Supabase session, so database policies still apply.
  • Background synchronization and internal newsletter operations use a server-side service client for tasks that need elevated access.

Keeping those paths distinct makes it easier to reason about which permission boundary protects each operation.

One content model for the blog, projects, and seminars

The long-form content types share a common backbone: slug, title, excerpt, featured image, Markdown body, publication status, publication timestamp, SEO fields, and external links. Blog posts add tags; projects and seminars add their own domain-specific fields.

I chose Markdown as the shared authoring format. The admin editor offers write and preview modes, while the public site uses a shared renderer built with react-markdown, GitHub-flavored Markdown support, and syntax highlighting.

This keeps a blog post and a project case study consistent without maintaining separate editing systems. The tradeoff is that authoring requires Markdown familiarity, which is acceptable for a site I maintain myself.

The surrounding publishing system matters just as much as the body text. Article routes, metadata, Open Graph images, structured data, and sitemap entries all need to agree about the content being published. Empty collections also need deliberate behavior: a useful empty state on the listing page, and no misleading placeholder cards on the homepage.

The admin dashboard needed real request testing

Supabase Auth protects the dashboard. The root proxy.ts refreshes session cookies and redirects unauthenticated requests, while the dashboard layout checks authentication again on the server.

The dashboard provides content management, Markdown previews, storage uploads, draft/published status, and SEO fields. Mutations trigger path revalidation so changes made through the application can reach the public site.

One of the clearest bugs appeared during an authenticated HTTP smoke test. The test reproduced Supabase's session-cookie handling and requested a local server running the production build as a signed-in user.

The dashboard returned HTTP 500, even though content appeared in the response. The server error identified the cause: a sidebar tooltip was being rendered without a TooltipProvider.

Adding the provider to the admin layout and repeating the same request produced a clean HTTP 200. A request without the session cookie still redirected.

That was a useful reminder to check status codes and server logs alongside what a page looks like. A successful build had not exercised that complete request path.

RLS: test the actual database boundary

Content-management writes use the authenticated session instead of automatically bypassing Row Level Security with a service-role key.

The development verification covered three concrete cases:

  1. An anonymous insert into a content table was rejected.
  2. An authenticated insert, update, and delete succeeded.
  3. An anonymous read could not see a draft row.

Those checks established specific behavior. They were not a blanket proof that every policy and endpoint was secure.

There is also an important scope limit: the original admin policies grant access to the authenticated role under a single-owner assumption. That is not a multi-user administrator role system. Before introducing other users, I would replace that assumption with explicit owner or role checks in the database and test non-admin accounts directly.

The newsletter security issue: a token is a credential

The newsletter uses double opt-in. A visitor submits an email address, receives a confirmation link, and confirms ownership through that link before receiving broadcasts.

The dangerous design would have been exposing the helper that creates a pending subscriber and returns its confirmation token as a callable Server Action. A caller could supply someone else's email address, obtain the token directly, and confirm the subscription without accessing that person's inbox.

The solution is a clear module boundary. lib/newsletter/service.ts contains the internal token-handling functions and is deliberately not a "use server" action module. The public action in lib/public-actions/newsletter.ts invokes the service, sends the confirmation email, and returns a success or error result without returning the tokens.

This was a design-level exposure risk identified and avoided during implementation; it is not evidence of an observed data breach. It is also separate from the RLS checks: the internal service uses elevated database access, so the public function's inputs and outputs need their own scrutiny.

The lesson is broader than newsletters. A function that is safe for internal callers can be unsafe as a public endpoint, especially when its return value contains a credential.

Newsletter and CRM workflows have different failure rules

The system keeps contacts and newsletter subscribers in separate tables because a client inquiry and an email subscription represent different relationships. Resend handles email delivery, while the dashboard manages subscribers and broadcasts.

Testing without the email provider configured exposed an important distinction. A contact inquiry could still be saved even if the administrator's notification email failed. A newsletter signup could create a pending record, but needed to report a delivery failure because the visitor could not confirm without the email.

The development checks also exercised confirmation, rejection of an already-used confirmation link while the subscriber was confirmed, invalid tokens, and unsubscribe behavior.

Follow-up automation was deliberately modest: a daily digest reminds me about contacts due for follow-up. It supports personal outreach without introducing a full automated sequence engine.

Social synchronization: choose the right data, then make it durable

GitHub, YouTube, Facebook, and Instagram synchronize into a shared social-posts table. The orchestrator catches failures per source, allowing successful platforms to continue when another fails.

A unique (platform, external_id) key supports upserts without duplicates. An administrator's hidden flag is omitted from sync updates, so a hidden item stays hidden after the next refresh.

The GitHub integration initially used the public events feed. It worked technically, but only a small part of my work appeared because recent activity is not the same thing as a repository showcase.

The implementation changed to listing public repositories, excluding forks and sorting by recent pushes. That was a product-model correction: the API needed to answer the question the page was actually asking.

Another issue appeared with time. Facebook and Instagram supplied signed CDN thumbnail URLs that later expired. A card that looked correct immediately after synchronization could lose its image days later.

The current sync copies those thumbnails into Supabase Storage and stores the resulting public URL. If a download fails, it tries to reuse a previous copy; otherwise it leaves the thumbnail empty. The tradeoff is additional storage and transfer work in exchange for more durable presentation.

The stale-page bug: database truth and cached HTML diverged

Admin edits already called revalidatePath(), but changes made through SQL migrations or Supabase Studio bypassed those actions.

When newly seeded content did not appear, the underlying issue was a cached build-time snapshot with no time-based revalidation fallback. The database had changed; the rendered page had not.

The fix added a one-hour revalidation interval to public content routes and a protected on-demand revalidation endpoint for out-of-band changes. Admin-triggered revalidation remained the immediate path for normal editing.

The interval makes a page eligible for regeneration; it should not be mistaken for an exact hourly push to every browser. The broader design lesson is to account for every writer to the database when deciding how readers become fresh.

Dark mode, portals, and hydration

The public site's visual theme and the admin's light/dark preference are separate concerns, even though they share one application document during navigation.

The original approach scoped the dark class to an admin wrapper. The current implementation also synchronizes the class on the document root while the admin is mounted, allowing portaled dialogs, menus, and selects to inherit the theme. Cleanup removes that root class when leaving the admin.

That evolution matters: containing styles inside a component subtree is not enough when some of its UI renders through portals elsewhere in the document.

The theme store provides a consistent light snapshot for server rendering and initial hydration, then reconciles with the stored or system preference. A later tech-stack animation fix addressed a related issue: applying the reduced-motion preference after hydration so the server and first client render agree.

What I would architect differently today

I would keep the core stack, but make a few responsibilities explicit earlier.

Turn the useful smoke checks into repeatable regression tests. Anonymous draft access, authenticated dashboard responses, publication freshness, and newsletter token boundaries deserve checks that survive beyond a one-off verification script.

Document access assumptions alongside each policy. A single-owner shortcut should be visible and easy to replace before the authentication model expands.

Design freshness around every write path. Admin actions, migrations, imports, and direct database edits all need a documented route to fresh public content.

Make background failures easier to investigate. Persistent sync history, last-success timestamps, and controlled retries would improve the operational picture beyond an immediate result from a sync run.

Move growing email work into durable jobs. As broadcast volume increases, queued delivery, per-recipient status, retries, and idempotency would be a better foundation than keeping the work tied to one request.

These are concrete next steps, rather than claims that the current implementation already solves every operational problem.

Building Ahsan DevHub gave me a useful standard for my own work: explain why a boundary exists, exercise it with a real request, and record what the result actually proves. This site is where I can keep applying that standard—and make the decisions and corrections visible as it evolves.

Decorative sparkNewsletter

Stay in the Loop

Occasional emails about new projects, blog posts, and what I'm building - no spam.