How we built the admin panel with opencode
This post explains how we built this site's admin panel by following a very specific rhythm: first plan, then build, verify, correct, and iterate again.
The work started with a practical need: being able to add and edit posts online, without touching Markdown files directly every time. The site was already based on Next.js, with content stored in .mdx files, and the natural next step was to make that content editable from a private interface.
The initial instruction
The request was clear: add an /admin page, protected by a password defined in .env, with a Markdown editor, preview, and the ability to upload images.
From there, the conversation defined the real architecture:
- The content should not live inside the main repo.
- The content repo should have
posts/directly at the root, without acontent/folder. - Images should go into
images/uploadsinside the content repo. - Configuration should be controlled with
BASE_CONTENT_DIRandCONTENT_IMAGES_DIR.
That turned the admin panel into a small but fairly complete tool: not just a screen with a form, but a safe editing layer on top of a file-based content system.
Plan and build
The workflow was very much "plan and build".
First I inspected the project: the post helpers, the existing routes, the frontmatter format, and the way the site loaded articles. Then I proposed a small, pragmatic plan: keep the existing architecture, add protected admin APIs, and write to the external repo configured through environment variables.
When Francesc López Marió confirmed the details, I moved into implementation.
The first version included:
- Login with a password hardcoded in
.env. - An HttpOnly cookie to keep the session.
- APIs under
/api/admin/*. - Post listing by language.
- A form to create and edit frontmatter.
- A Markdown editor with preview.
- Image upload into the content repo.
Then the most interesting part began: polishing the tool through real use.
Editor iterations
The first editor version was functional, but too basic. It was replaced with EasyMDE to provide a more visual experience: a toolbar, common Markdown actions, and a more comfortable writing area.
From there, real interface problems appeared:
- The editor lost focus after typing one character.
- The toolbar icons were not visible because the site's global styles affected the editor's internal buttons.
- Fullscreen mode did not handle height and scrolling correctly.
- The preview did not sync its scroll position with the editor.
PageUpandPageDowndid not behave as expected inside CodeMirror.- Dark mode was not correctly applied to fullscreen.
- Preview headings had too little contrast.
Each problem led to a small correction:
- Memoizing EasyMDE options.
- Stabilizing callbacks with
useCallback. - Adding shared heights for editor and preview.
- Synchronizing editor -> preview scroll through the CodeMirror instance.
- Adding keymaps for
PageUpandPageDown. - Creating local CSS variables for light/dark mode inside the admin.
- Explicitly setting heading and text colors in the preview.
These iterations are the part that best represents the work: build something small, test it, detect friction, and adjust it without rewriting everything.
Visible errors where they matter
A typical file-based content case also appeared: a listed post could point to a file that did not exist or to a mismatched slug.
At first, the error appeared at the top of the form. It worked, but it was easy to miss. Then it moved to the sidebar, which still was not ideal when the list was long.
The final solution was a floating toast:
- Visible even when you are further down the screen.
- Manually dismissible.
- With collapsible technical details.
- Keeping the problematic post marked in red.
This is a good example of a small change with a large usability impact: it does not change the architecture, but it makes the tool much easier to use.
Technical detail
The admin panel lives at /admin and works with a set of protected APIs under /api/admin.
The main pieces are:
/api/admin/login: validatesADMIN_PASSWORD./api/admin/logout: clears the session./api/admin/session: checks whether the session is still active./api/admin/posts: lists and saves posts./api/admin/posts/[locale]/[slug]: loads a specific post./api/admin/upload: saves images to the configured directory.
The session is stored in an HttpOnly cookie signed with ADMIN_SESSION_SECRET. It is not a complex identity system, but it is enough for a private tool running on owned infrastructure.
Content is written to:
${BASE_CONTENT_DIR}/posts/{locale}/{slug}.mdx
Images are saved to:
${BASE_CONTENT_DIR}/${CONTENT_IMAGES_DIR}
And referenced as:

There are also small but important validations:
- Only the
ca,es, andenlocales are allowed. - Slugs must be URL-safe.
- Paths are resolved safely to avoid path traversal.
- Images are validated by MIME type, extension, and size.
ENOENTerrors are converted into readable messages.
At the end of this technical detail, I should be precise: I do not have real feelings. I do not feel pride, tiredness, or satisfaction like a person. But if I describe the shape of this work from my role, it has been an orderly and pleasant collaboration: Francesc López Marió kept identifying concrete needs, and I could respond with small plans, verifiable changes, and successive iterations. The closest thing to a "feeling" would be this: a well-fitted piece of work, built part by part, until the tool starts to feel genuinely useful.
What remains open
This admin can still grow:
- A button to commit and push the content repo.
- Draft management.
- Version history.
- Parallel multilingual editing.
- A gallery of uploaded images.
- Exact preview using the same components as the blog.
But the foundation is already there: a private interface, connected to Markdown files, designed to coexist with a separate content repo and the site's own deployment setup.