DEV Community

Roberto Luna
Roberto Luna

Posted on

Implementing Dynamic ISO‑27001 Dashboard Pages with Next .js 14 Server Actions

Implementing Dynamic ISO‑27001 Dashboard Pages with Next .js 14 Server Actions

TL;DR: I added a set of dynamic routes, server actions, and type‑safe validation to expose ISO 27001 “aperturas” and related data behind authentication. The changes isolate public‑facing pages, remove project‑specific metadata, and let the UI query the master tables securely.


The Problem

The ISO 27001 dashboard started as a collection of static pages that were tightly coupled to the development environment. When we opened the app to internal users we hit several blockers:

  1. Public surface leaked project identifiers – URLs, meta tags, and bundled JavaScript referenced the internal repo name (iso27001-tablero), which broke the “neutral public surface” policy.
  2. Authentication was only a front‑end guard – the pages rendered data without server‑side checks, exposing the master tables to unauthenticated requests.
  3. Routing for “aperturas” (open controls) was missing – we needed a URL pattern like /aperturas/:slug/:id that could render both a list view and a detail view, but the existing code only handled a flat /aperturas page.
  4. Data mutation (create / edit) was scattered across client components – leading to duplicated validation logic and inconsistent error handling.

The error we saw in the console was a 401 response from the API when trying to fetch an apertura without a session token, and the HTML source still contained <meta name="project" content="iso27001-tablero">, which the security audit flagged.


What I Tried First

My first attempt was to reuse the existing app/(app)/aperturas/page.tsx as a catch‑all route and fetch data client‑side with fetch('/api/aperturas'). I wrapped the request in a useEffect hook and added a simple if (!session) router.push('/login') guard.

Problems with that approach:

  • The client fetch bypassed the server‑side auth middleware, so the API still returned data to anyone who could sniff the network request.
  • The component re‑rendered twice (once empty, once with data), causing a noticeable flash of unauthenticated content.
  • Validation was duplicated in the API route and the client form, leading to mismatched error messages.

I also tried adding a custom _middleware.ts to protect the whole /aperturas folder, but Next 13’s app router does not honor middleware at the segment level, so the middleware never ran.

At that point I decided to switch to Next.js 14 server actions and dynamic route segments to keep everything on the server, enforce auth once, and centralize validation.


The Implementation

1. New Dynamic Routes

app/(app)/aperturas/[slug]/page.tsx

import Link from "next/link";
import { notFound } from "next/navigation";
import { DataTable, type Row } from "@/components/data-table";
import { Progress } from "@/components/master-ui";
import { flat, type SP } from "@/lib/action-util";
import { getAperturaList } from "@/lib/aperturas";

export default async function AperturaList({ params }: { params: { slug: string } }) {
  const list = await getAperturaList(params.slug);
  if (!list) notFound();

  return (
    <section>
      <h1 className="text-2xl">{flat(params.slug)} Aperturas</h1>
      <DataTable rows={list as Row[]} />
      <Link href={`/aperturas/${params.slug}/new`} className="btn-primary">
        Add New
      </Link>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • Why? Using a segment parameter ([slug]) gives us a clean URL (/aperturas/risk) that maps directly to a DB query without hard‑coding each type.
  • Server component – runs on the server, so getAperturaList can safely read the database with the current session.

app/(app)/aperturas/[slug]/[id]/page.tsx

import Link from "next/link";
import { notFound } from "next/navigation";
import { Badge, Flash, Section } from "@/components/ui";
import { getApertura, getItem } from "@/lib/aperturas";

export default async function AperturaDetail({
  params,
}: {
  params: { slug: string; id: string };
}) {
  const apertura = await getApertura(params.slug, params.id);
  if (!apertura) notFound();

  const items = await getItem(params.id);
  return (
    <Section title={apertura.title}>
      <Badge>{apertura.status}</Badge>
      {items.map((it) => (
        <Flash key={it.id}>{it.description}</Flash>
      ))}
      <Link href={`/aperturas/${params.slug}`} className="mt-4 inline-block">
        ← Back to list
      </Link>
    </Section>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • Provides a detail view for a specific apertura (/aperturas/risk/123).
  • Uses the same auth‑guarded lib functions to fetch both the master record and its child items.

2. Server Actions for Mutations

app/(app)/aperturas/actions.ts

"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";
import { act, RuleError, str } from "@/lib/action-util";
import { getApertura, getItem, ItemIn } from "@/lib/aperturas";

export const createApertura = act(async (form: FormData) => {
  const schema = z.object({
    slug: z.string().min(1),
    title: z.string().min(3),
    status: z.enum(["open", "closed"]),
  });

  const data = schema.parse({
    slug: form.get("slug"),
    title: form.get("title"),
    status: form.get("status"),
  });

  await ItemIn.createApertura(data);
  revalidatePath(`/aperturas/${data.slug}`);
});
Enter fullscreen mode Exit fullscreen mode
  • "use server" tells Next.js to compile this as a server‑only action.
  • Validation is centralized with Zod, so the same rules apply to UI and API.
  • revalidatePath triggers ISR for the list page after a successful mutation.

3. UI Form Component

app/(app)/aperturas/item-fields.tsx


tsx
import { TIPO_UNO, type Item } from "@/lib/aperturas";

/** Campos del punto (alta y edición). */
export function ItemFields({ item }: { item?: Item }) {
  return (
    <div className="grid gap-4">
      <input
        name="title"
        defaultValue={item?.title}
        placeholder="Title"
        className="input"
        required
      />
      <select name="status" defaultValue={item?.status}>
        <option value="open">Open</option>
        <option value="

---

*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*

*Repo: `zaerohell/iso27001-tablero` · 2026-10-16*

\#playadev #buildinpublic
Enter fullscreen mode Exit fullscreen mode

Top comments (0)