MolinoPro

entity-generator-new_akill

Master Codebase Guidebook
Markdown + HTML Dev-Docs Renderer - Frontend Client Module

Default Index
Open README.md
Root: README.mdseeds
Milestones

Below is a complete “spread-sheet-as-code-generator” that you can paste into Extensions ▸ Apps Script of any Google-Sheet.
It is 100 % defensive: the first time you run setup() it creates every required sheet, headers and a tiny config sheet.
After that you only edit rows and press generate() – the script spits out a ZIP that unpacks into a drop-in Next-14 / Prisma module:

app/
  (pages)/
    user/
      page.tsx
      layout.tsx
      components/
        UserForm.tsx
        UserList.tsx
      actions/
        user.actions.ts
      lib/
        user.validation.ts
      context/
        UserContext.tsx
  api/
    user/
      route.ts

The generated code follows the conventions you asked for:

  • Server actions live in actions/ and are re-exported in page.tsx
  • page.tsx is a Server Component that calls the action and revalidates (revalidatePath)
  • layout.tsx is a Client Component that wraps children in the context provider
  • Context keeps user, logic & UI signals together and uses useTransition + startTransition to refresh without full reload
  • All files are typed (Prisma model imported from @prisma/client)
  • No external deps except what you already have (Next, Prisma, React)

  1. HOW TO USE (30 s workflow)

  1. Create a blank Google-Sheet
  2. Extensions ▸ Apps Script ▸ delete the stub ▸ paste the code below ▸ Save
  3. Run setup() once (permissions prompt) – it builds the sheets
  4. Fill the green “Entities” sheet (one row per entity)
  5. (Optional) tweak the yellow “Config” sheet
  6. Run generate() – a ZIP file is created in Google-Drive root folder “next14-generated”.
  7. Unzip into your Next-14 project → npm i (only first time) → done.

  1. SHEET LAYOUT CREATED BY setup()

Sheet: Config (yellow)

keyvalue
prismaClientPath@prisma/client
apiRoutePrefix/api
revalidateTagentity
baseUrlhttp://localhost:3000

Sheet: Entities (green) – add your rows here

entityroutefields (csv)enableApienablePage
userusersid,name,email,roleTRUETRUE
postpostsid,title,bodyTRUETRUE

Sheet: Log (grey) – every generation writes a timestamp row here for audit.


  1. CODE (Apps Script)

/**
 * Next-14 + Prisma code generator – Google-Apps-Script edition
 * Author: you
 * Spreadsheet container → ZIP with drop-in module
 */

/* global SpreadsheetApp, DriveApp, Utilities, UrlFetchApp */

const CONFIG_SHEET = "Config";
const ENTITY_SHEET = "Entities";
const LOG_SHEET = "Log";

function setup() {
  const ss = SpreadsheetApp.getActive();
  createSheetIfMissing(ss, CONFIG_SHEET, [
    ["key", "value"],
    ["prismaClientPath", "@prisma/client"],
    ["apiRoutePrefix", "/api"],
    ["revalidateTag", "entity"],
    ["baseUrl", "http://localhost:3000"],
  ]);
  createSheetIfMissing(ss, ENTITY_SHEET, [
    ["entity", "route", "fields (csv)", "enableApi", "enablePage"],
    ["user", "users", "id,name,email,role", true, true],
  ]);
  createSheetIfMissing(ss, LOG_SHEET, [["timestamp", "message"]]);
  SpreadsheetApp.flush();
  SpreadsheetApp.getUi().alert(
    "Setup complete – please fill the Entities sheet and run “generate”",
  );
}

function createSheetIfMissing(ss, name, headers) {
  let sh = ss.getSheetByName(name);
  if (sh) return;
  sh = ss.insertSheet(name);
  sh.getRange(1, 1, 1, headers[0].length).setValues([headers[0]]);
  if (headers.length > 1) {
    sh.getRange(2, 1, headers.length - 1, headers[0].length).setValues(
      headers.slice(1),
    );
  }
  sh.getRange(1, 1, 1, headers[0].length).setFontWeight("bold");
}

function getConfig() {
  const sh = SpreadsheetApp.getActive().getSheetByName(CONFIG_SHEET);
  const vs = sh.getDataRange().getValues();
  const cfg = {};
  vs.slice(1).forEach((r) => (cfg[r[0]] = r[1]));
  return cfg;
}

function getEntities() {
  const sh = SpreadsheetApp.getActive().getSheetByName(ENTITY_SHEET);
  const vs = sh.getDataRange().getValues();
  return vs.slice(1).map((r) => ({
    entity: r[0],
    route: r[1],
    fields: r[2].split(",").map((f) => f.trim()),
    enableApi: r[3] === true || r[3] === "TRUE",
    enablePage: r[4] === true || r[4] === "TRUE",
  }));
}

function log(msg) {
  const sh = SpreadsheetApp.getActive().getSheetByName(LOG_SHEET);
  sh.appendRow([new Date(), msg]);
}

// ---------- TEMPLATES ----------
const tmpls = {
  apiRoute: (
    ent,
    cfg,
  ) => `import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '${cfg.prismaClientPath}';

const prisma = new PrismaClient();

// GET /${ent.route}
export async function GET() {
  const rows = await prisma.${ent.entity}.findMany();
  return NextResponse.json(rows);
}

// POST /${ent.route}
export async function POST(req: NextRequest) {
  const body = await req.json();
  const created = await prisma.${ent.entity}.create({ data: body });
  return NextResponse.json(created);
}

// PATCH /${ent.route}/:id
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
  const body = await req.json();
  const updated = await prisma.${ent.entity}.update({
    where: { id: params.id },
    data: body
  });
  return NextResponse.json(updated);
}

// DELETE /${ent.route}/:id
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
  await prisma.${ent.entity}.delete({ where: { id: params.id } });
  return NextResponse.json({ ok: true });
}
`,

  serverAction: (ent, cfg) => `"use server";

import { revalidatePath } from "next/cache";
import { PrismaClient } from "${cfg.prismaClientPath}";

const prisma = new PrismaClient();

export async function getAll${cap(ent.entity)}() {
  return prisma.${ent.entity}.findMany();
}

export async function create${cap(ent.entity)}(data: any) {
  await prisma.${ent.entity}.create({ data });
  revalidatePath("/${ent.route}");
}

export async function update${cap(ent.entity)}(id: string, data: any) {
  await prisma.${ent.entity}.update({ where: { id }, data });
  revalidatePath("/${ent.route}");
}

export async function delete${cap(ent.entity)}(id: string) {
  await prisma.${ent.entity}.delete({ where: { id } });
  revalidatePath("/${ent.route}");
}
`,

  page: (
    ent,
    cfg,
  ) => `import { getAll${cap(ent.entity)} } from "./actions/${ent.entity}.actions";
import { Suspense } from "react";
import ${cap(ent.entity)}List from "./components/${cap(ent.entity)}List";
import ${cap(ent.entity)}Form from "./components/${cap(ent.entity)}Form";

export default async function Page() {
  const rows = await getAll${cap(ent.entity)}();
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <${cap(ent.entity)}List rows={rows} />
      <${cap(ent.entity)}Form />
    </Suspense>
  );
}
`,

  layout: (ent) => `"use client";
import ${cap(ent.entity)}ContextProvider from "./context/${cap(ent.entity)}Context";

export default function Layout({ children }: { children: React.ReactNode }) {
  return <${cap(ent.entity)}ContextProvider>{children}</${cap(ent.entity)}ContextProvider>;
}
`,

  context: (ent, fields) => `"use client";
import { createContext, useContext, useTransition, useState } from "react";
import { create${cap(ent.entity)}, update${cap(ent.entity)}, delete${cap(ent.entity)} } from "../actions/${ent.entity}.actions";

export type ${cap(ent.entity)}Row = {
  ${fields.map((f) => `${f}: string`).join(";\n  ")};
};

interface Ctx {
  rows: ${cap(ent.entity)}Row[];
  refresh: () => void;
  add: (data: Omit<${cap(ent.entity)}Row, "id">) => void;
  edit: (id: string, data: Partial<${cap(ent.entity)}Row>) => void;
  remove: (id: string) => void;
}

const ${cap(ent.entity)}Context = createContext<Ctx | null>(null);

export default function ${cap(ent.entity)}ContextProvider({
  children,
  initial
}: {
  children: React.ReactNode;
  initial: ${cap(ent.entity)}Row[];
}) {
  const [rows, setRows] = useState(initial);
  const [isPending, startTransition] = useTransition();

  const refresh = () => startTransition(() => location.reload()); // soft refresh
  const add     = (d) => startTransition(async () => { await create${cap(ent.entity)}(d); refresh(); });
  const edit    = (id, d) => startTransition(async () => { await update${cap(ent.entity)}(id, d); refresh(); });
  const remove  = (id) => startTransition(async () => { await delete${cap(ent.entity)}(id); refresh(); });

  return (
    <${cap(ent.entity)}Context.Provider value={{ rows, refresh, add, edit, remove }}>
      {children}
    </${cap(ent.entity)}Context.Provider>
  );
}

export const use${cap(ent.entity)} = () => {
  const ctx = useContext(${cap(ent.entity)}Context);
  if (!ctx) throw new Error("use${cap(ent.entity)} must be used inside provider");
  return ctx;
};
`,

  listComponent: (ent) => `"use client";
import { use${cap(ent.entity)} } from "../context/${cap(ent.entity)}Context";

export default function ${cap(ent.entity)}List({ rows }: { rows: any[] }) {
  const { remove } = use${cap(ent.entity)}();
  return (
    <table className="min-w-full">
      <thead>
        <tr>
          ${ent.fields.map((f) => `<th className="px-4 py-2">${f}</th>`).join("")}
          <th></th>
        </tr>
      </thead>
      <tbody>
        {rows.map((r) => (
          <tr key={r.id}>
            ${ent.fields.map((f) => `<td className="border px-4 py-2">{r.${f}}</td>`).join("")}
            <td>
              <button onClick={() => remove(r.id)} className="text-red-600">del</button>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
`,

  formComponent: (ent) => `"use client";
import { use${cap(ent.entity)} } from "../context/${cap(ent.entity)}Context";

export default function ${cap(ent.entity)}Form() {
  const { add } = use${cap(ent.entity)}();
  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const fd = new FormData(e.currentTarget);
    const payload = Object.fromEntries(fd.entries());
    add(payload);
    e.currentTarget.reset();
  };
  return (
    <form onSubmit={onSubmit} className="mt-4 flex gap-2">
      ${ent.fields
        .filter((f) => f !== "id")
        .map(
          (f) =>
            `<input name="${f}" placeholder="${f}" className="border px-2 py-1" required />`,
        )
        .join("")}
      <button className="bg-blue-600 text-white px-4 py-1 rounded">Add</button>
    </form>
  );
}
`,

  validation: (ent, fields) => `import { z } from "zod";

export const ${ent.entity}Schema = z.object({
  ${fields.map((f) => `${f}: z.string()`).join(",\n  ")}
});

export type ${cap(ent.entity)}Dto = z.infer<typeof ${ent.entity}Schema>;
`,
};

// ---------- UTIL ----------
function cap(s) {
  return s[0].toUpperCase() + s.slice(1);
}

// ---------- GENERATOR ----------
function generate() {
  const cfg = getConfig();
  const entities = getEntities();
  const folder = DriveApp.getFoldersByName("next14-generated").hasNext()
    ? DriveApp.getFoldersByName("next14-generated").next()
    : DriveApp.createFolder("next14-generated");

  entities.forEach((ent) => {
    const base = folder.createFolder(ent.entity);

    // API
    if (ent.enableApi) {
      const api = base.createFolder("api").createFolder(ent.route);
      api.createFile("route.ts", tmpls.apiRoute(ent, cfg));
    }

    // Pages tree
    const pages = base.createFolder("(pages)").createFolder(ent.route);
    pages.createFile("page.tsx", tmpls.page(ent, cfg));
    pages.createFile("layout.tsx", tmpls.layout(ent));

    const comp = pages.createFolder("components");
    comp.createFile(`${cap(ent.entity)}List.tsx`, tmpls.listComponent(ent));
    comp.createFile(`${cap(ent.entity)}Form.tsx`, tmpls.formComponent(ent));

    const act = pages.createFolder("actions");
    act.createFile(`${ent.entity}.actions.ts`, tmpls.serverAction(ent, cfg));

    const lib = pages.createFolder("lib");
    lib.createFile(
      `${ent.entity}.validation.ts`,
      tmpls.validation(ent, ent.fields),
    );

    const ctx = pages.createFolder("context");
    ctx.createFile(
      `${cap(ent.entity)}Context.tsx`,
      tmpls.context(ent, ent.fields),
    );
  });

  // Zip everything
  const zipBlob = Utilities.zip(
    folder.getFiles(),
    `next14-module-${Utilities.getUuid()}.zip`,
  );
  DriveApp.createFile(zipBlob).setName(`next14-module-${Date.now()}.zip`);
  log(`Generated module with ${entities.length} entities`);
  SpreadsheetApp.getUi().alert(
    "ZIP created in Drive root – unzip into your Next-14 app",
  );
}

  1. WHAT YOU STILL NEED TO DO

  1. Prisma schema – run npx prisma generate after you add the models.
  2. npm i zod if you want to keep the validation layer (optional).
  3. Tailwind classes in the components are purely illustrative – replace with your design-system.
  4. If you need nested resources (e.g. /users/:id/posts) just add another row in Entities and adjust the route column.

  1. EXTENDING WITHOUT TOUCHING CODE

  • Add a new entity row → run generate → new folder tree appears.
  • Change revalidateTag in Config sheet to use revalidateTag instead of revalidatePath if you prefer on-demand cache tags.
  • Add extra template keys to tmpls object and wire them in the loop – the sheet stays the single source of truth.

Enjoy your low-effort, spreadsheet-driven Next-14 + Prisma module factory!