pure-TypeScript, zero-Apps-Script for Next-14 + Prisma repo
• The “source of truth” is a tiny JSON file (entities.json) that you can edit by hand or expose via an internal UI. • A single CLI command (npm run generate) scans that file and writes / updates the folders under app/(pages)/<entity>/… idempotently (it will never overwrite files you touched manually – opt-in flag required). • All templates are plain TS functions – easy to extend. • Prisma is still only imported inside actions/; everything else is pure.
- INSTALL (once)
H1inside your Next-14 repo
npm i -D tsx chokicolors zod # tsx = run TS without build
Add to package.json: "scripts": { "generate": "tsx scripts/generateEntities.ts" }
- REPO STRUCTURE AFTER YOU DROP THE FILES ------------------------------------------------``` repo/ ├─ prisma/ │ └─ schema.prisma # your models ├─ scripts/ │ ├─ entities.json # source of truth │ ├─ generateEntities.ts # generator entry │ └─ templates/ # plain TS functions │ ├─ page.ts.ts │ ├─ layout.ts.ts │ ├─ actions.ts.ts │ ├─ context.ts.ts │ ├─ list.tsx.ts │ ├─ form.tsx.ts │ ├─ types.ts.ts │ └─ index.ts.ts ├─ app/ │ └─ (pages)/ # ← generator writes here └─ …
- entities.json (replace with your own)
[
{
"name": "user",
"route": "users",
"fields": ["id", "name", "email", "role"],
"enableApi": true,
"enablePage": true
},
{
"name": "post",
"route": "posts",
"fields": ["id", "title", "body", "published"],
"enableApi": true,
"enablePage": true
}
]
----
4. scripts/generateEntities.ts
----
#!/usr/bin/env tsx
/**
* Next-14 + Prisma – canonical entity-module generator
* Idempotent: safe to run as often as you like.
* CLI: npm run generate
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import chalk from 'chalk';
import { z } from 'zod';
/* ---------- config ---------- */
const APP_PAGES_ROOT = resolve(process.cwd(), 'app/(pages)');
const ENTITIES_PATH = resolve(process.cwd(), 'scripts/entities.json');
/* ---------- schemas ---------- */
const Entity = z.object({
name: z.string(),
route: z.string(),
fields: z.array(z.string()),
enableApi: z.boolean().optional().default(true),
enablePage: z.boolean().optional().default(true),
});
type Entity = z.infer<typeof Entity>;
/* ---------- small utils ---------- */
const cap = (s: string) => s[0].toUpperCase() + s.slice(1);
const log = (msg: string) => console.log(chalk.green('✔'), msg);
/* ---------- templates ---------- */
// each template is a plain function (entity, fields) => string
const T = {
page: (e: Entity) =>
`import { getAll${cap(e.name)} } from "./actions/${e.name}.actions";
import { Suspense } from "react";
import ${cap(e.name)}List from "./components/${cap(e.name)}List";
import ${cap(e.name)}Form from "./components/${cap(e.name)}Form";
export default async function Page() {
const rows = await getAll${cap(e.name)}();
return (
<Suspense fallback={<p>Loading…</p>}>
<${cap(e.name)}List rows={rows} />
<${cap(e.name)}Form />
</Suspense>
);
}
`,
layout: (e: Entity) =>
`"use client";
import ${cap(e.name)}Provider from "./context/${cap(e.name)}Context";
export default function Layout({ children }: { children: React.ReactNode }) {
return <${cap(e.name)}Provider initial={[]}>{children}</${cap(e.name)}Provider>;
}
`,
actions: (e: Entity) =>
`"use server";
import { revalidatePath } from "next/cache";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export async function getAll${cap(e.name)}() {
return prisma.${e.name}.findMany();
}
export async function create${cap(e.name)}(data: any) {
await prisma.${e.name}.create({ data });
revalidatePath("/${e.route}");
}
export async function update${cap(e.name)}(id: string, data: any) {
await prisma.${e.name}.update({ where: { id }, data });
revalidatePath("/${e.route}");
}
export async function delete${cap(e.name)}(id: string) {
await prisma.${e.name}.delete({ where: { id } });
revalidatePath("/${e.route}");
}
`,
context: (e: Entity) =>
`"use client";
import { createContext, useContext, useTransition, useState } from "react";
import { create${cap(e.name)}, update${cap(e.name)}, delete${cap(e.name)} } from "../actions/${e.name}.actions";
type Row = { ${e.fields.map(f => `${f}: string`).join('; ')} };
interface Ctx {
rows: Row[];
refresh: () => void;
add: (d: Omit<Row, "id">) => void;
edit: (id: string, d: Partial<Row>) => void;
remove: (id: string) => void;
}
const C = createContext<Ctx | null>(null);
export default function ${cap(e.name)}Provider({ children, initial }: { children: React.ReactNode; initial: Row[] }) {
const [rows, setRows] = useState(initial);
const [isP, start] = useTransition();
const refresh = () => start(() => location.reload());
const add = (d) => start(async () => { await create${cap(e.name)}(d); refresh(); });
const edit = (id, d) => start(async () => { await update${cap(e.name)}(id, d); refresh(); });
const remove = (id) => start(async () => { await delete${cap(e.name)}(id); refresh(); });
return <C.Provider value={{ rows, refresh, add, edit, remove }}>{children}</C.Provider>;
}
export const use${cap(e.name)} = () => {
const c = useContext(C);
if (!c) throw new Error("use${cap(e.name)} used outside provider");
return c;
};
`,
list: (e: Entity) =>
`"use client";
import { use${cap(e.name)} } from "../context/${cap(e.name)}Context";
export default function ${cap(e.name)}List({ rows }: { rows: any[] }) {
const { remove } = use${cap(e.name)}();
return (
<table className="min-w-full">
<thead>
<tr>${e.fields.map(f => `<th>${f}</th>`).join('')}<th></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
${e.fields.map(f => `<td>{r.${f}}</td>`).join('')}
<td><button onClick={() => remove(r.id)}>del</button></td>
</tr>
))}
</tbody>
</table>
);
}
`,
form: (e: Entity) =>
`"use client";
import { use${cap(e.name)} } from "../context/${cap(e.name)}Context";
export default function ${cap(e.name)}Form() {
const { add } = use${cap(e.name)}();
const onSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
const d = Object.fromEntries(new FormData(ev.currentTarget).entries());
add(d); ev.currentTarget.reset();
};
return (
<form onSubmit={onSubmit} className="flex gap-2 mt-4">
${e.fields.filter(f => f !== 'id').map(f => `<input name="${f}" placeholder="${f}" required />`).join('')}
<button>Add</button>
</form>
);
}
`,
types: (e: Entity) =>
`export interface ${cap(e.name)} {
${e.fields.map(f => `${f}: string`).join(';\n ')}
}
`,
index: () => `// barrel\n`,
};
/* ---------- writer ---------- */
function writeIfNotExists(fullPath: string, content: string, force = false) {
if (!force && existsSync(fullPath)) return;
writeFileSync(fullPath, content, 'utf8');
log(`wrote ${fullPath.replace(process.cwd(), '')}`);
}
function buildEntityModule(e: Entity) {
const base = join(APP_PAGES_ROOT, e.route);
mkdirSync(base, { recursive: true });
// folders
['components', 'actions', 'context', 'lib', 'types'].forEach(f => mkdirSync(join(base, f), { recursive: true }));
// files
writeIfNotExists(join(base, 'page.tsx'), T.page(e));
writeIfNotExists(join(base, 'layout.tsx'), T.layout(e));
writeIfNotExists(join(base, 'index.ts'), T.index());
writeIfNotExists(join(base, 'components/index.ts'), T.index());
writeIfNotExists(join(base, 'actions/index.ts'), T.index());
writeIfNotExists(join(base, 'lib/index.ts'), T.index());
writeIfNotExists(join(base, 'context/index.ts'), T.index());
writeIfNotExists(join(base, 'types/index.ts'), T.types(e));
writeIfNotExists(join(base, `actions/${e.name}.actions.ts`), T.actions(e));
writeIfNotExists(join(base, `context/${cap(e.name)}Context.tsx`), T.context(e));
writeIfNotExists(join(base, `components/${cap(e.name)}List.tsx`), T.list(e));
writeIfNotExists(join(base, `components/${cap(e.name)}Form.tsx`), T.form(e));
}
/* ---------- main ---------- */
(function main() {
console.log(chalk.bold('🚀 Entity generator – Next-14 + Prisma\n'));
const raw = require(ENTITIES_PATH);
const entities = z.array(Entity).parse(raw);
entities.forEach(buildEntityModule);
console.log(chalk.bold('\n✅ All modules generated – ready to `npm run dev`'));
})();
----
5. WORKFLOW (every day)
----
1. Edit scripts/entities.json (add / rename / remove entities).
2. npm run generate
3. npm run dev – new routes are instantly available.
----
6. EXTEND / CUSTOMISE
----
• Want different templates? Just edit the plain functions in T.
• Want a tiny internal UI instead of JSON? Create app/(admin)/generator/page.tsx that POSTs to a server action which writes the same JSON and spawns the same generator – no extra security risk, it’s all local.
• Need nested resources? Add a parentRoute key to the JSON and tweak the templates – the rest of the plumbing stays identical.
You now have a 100 % TypeScript, zero-Apps-Script entity factory that ships with your codebase and follows the canonical Next-14 + Prisma patterns you defined.