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 inpage.tsx page.tsxis a Server Component that calls the action and revalidates (revalidatePath)layout.tsxis a Client Component that wraps children in the context provider- Context keeps user, logic & UI signals together and uses
useTransition+startTransitionto 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)
- HOW TO USE (30 s workflow)
- Create a blank Google-Sheet
- Extensions ▸ Apps Script ▸ delete the stub ▸ paste the code below ▸ Save
- Run
setup()once (permissions prompt) – it builds the sheets - Fill the green “Entities” sheet (one row per entity)
- (Optional) tweak the yellow “Config” sheet
- Run
generate()– a ZIP file is created in Google-Drive root folder “next14-generated”. - Unzip into your Next-14 project →
npm i(only first time) → done.
- SHEET LAYOUT CREATED BY setup()
Sheet: Config (yellow)
| key | value |
|---|---|
| prismaClientPath | @prisma/client |
| apiRoutePrefix | /api |
| revalidateTag | entity |
| baseUrl | http://localhost:3000 |
Sheet: Entities (green) – add your rows here
| entity | route | fields (csv) | enableApi | enablePage |
|---|---|---|---|---|
| user | users | id,name,email,role | TRUE | TRUE |
| post | posts | id,title,body | TRUE | TRUE |
Sheet: Log (grey) – every generation writes a timestamp row here for audit.
- 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",
);
}
- WHAT YOU STILL NEED TO DO
- Prisma schema – run
npx prisma generateafter you add the models. npm i zodif you want to keep the validation layer (optional).- Tailwind classes in the components are purely illustrative – replace with your design-system.
- If you need nested resources (e.g.
/users/:id/posts) just add another row in Entities and adjust the route column.
- EXTENDING WITHOUT TOUCHING CODE
- Add a new entity row → run
generate→ new folder tree appears. - Change
revalidateTagin Config sheet to userevalidateTaginstead ofrevalidatePathif you prefer on-demand cache tags. - Add extra template keys to
tmplsobject 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!