Next.js + Hono + Cloudflare + Prisma
This tutorial will guide you through setting up a full-stack application progressively, starting from a simple frontend and backend, and incrementally adding features like database integration, validation, and documentation.
Full Stack Tutorial: Next.js + Hono + Cloudflare + Prisma
This tutorial will guide you through setting up a full-stack application progressively, starting from a simple frontend and backend, and incrementally adding features like database integration, validation, and documentation.
Prerequisites
- Node.js (v18 or later)
- npm or pnpm
- A Cloudflare account (for deploying the backend)
Step 1: Create a Simple Next.js App
First, we'll set up the frontend using Next.js.
-
Initialize the project:
pnpm create next-app@latest web # Select the following options: # - TypeScript: Yes # - ESLint: Yes # - Tailwind CSS: Yes # - `src/` directory: Yes # - App Router: Yes # - Customize import alias: No -
Navigate to the web directory:
cd web -
Create a Contact Form: Open
src/app/page.tsxand replace the content with a simple form.// src/app/page.tsx "use client"; import { useState } from "react"; export default function Home() { const [name, setName] = useState(""); const [phone, setPhone] = useState(""); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const data = { name, phone }; console.log("Form Data:", data); alert(JSON.stringify(data, null, 2)); }; return ( <div className="flex min-h-screen flex-col items-center justify-center p-24"> <h1 className="mb-8 text-4xl font-bold">Add Contact</h1> <form onSubmit={handleSubmit} className="flex w-full max-w-md flex-col gap-4" > <input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} className="rounded border p-2 text-black" required /> <input type="tel" placeholder="Phone" value={phone} onChange={(e) => setPhone(e.target.value)} className="rounded border p-2 text-black" required /> <button type="submit" className="rounded bg-blue-500 p-2 text-white hover:bg-blue-600" > Submit </button> </form> </div> ); } -
Run the frontend:
pnpm devOpen
http://localhost:3000and test the form. You should see the data logged in your browser console.
Step 2: Create a Simple Hono App (Backend)
Now, let's set up the backend with Hono and Cloudflare Workers.
-
Initialize the project: Go back to the root directory and create the api project.
cd .. npm create hono@latest api # Select the following options: # - Template: Nodejs # - Install dependencies: Yes -
Navigate to the api directory:
cd api -
Configure Port 8000: Open
package.jsonand update thedevscript to run on port 8000.serve( { fetch: app.fetch, port: 8787, }, (info) => { console.log(`Server is running on http://localhost:${info.port}`); } ); -
Create a POST Endpoint: Open
src/index.tsand set up the server.// src/index.ts import { Hono } from "hono"; import { cors } from "hono/cors"; const app = new Hono(); // Enable CORS so our frontend can talk to the backend app.use("/*", cors()); app.get("/", (c) => { return c.text("Hello Hono!"); }); app.post("/contacts", async (c) => { const body = await c.req.json(); console.log("Received data:", body); return c.json({ message: "Data received", data: body }); }); export default app; -
Run the backend:
pnpm devThe server is now listening on
http://localhost:8787.
Step 3: Connect Frontend to Backend
Now we will update the frontend to send data to our new Hono API.
-
Update
src/app/page.tsxin thewebdirectory:// src/app/page.tsx // ... imports export default function Home() { // ... state const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const data = { name, phone }; try { const response = await fetch("http://localhost:8000/contacts", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(data), }); const result = await response.json(); console.log("Server Response:", result); alert("Saved! Check server console."); } catch (error) { console.error("Error:", error); alert("Failed to save."); } }; // ... return } -
Test it: Run both frontend and backend. Submit the form on the frontend. You should see "Received data: ..." in the terminal where the Hono app is running.
Step 4: Add Prisma Integration
Let's add a database to store our contacts.
-
Install Prisma and Postgres driver in
api:pnpm install prisma tsx @types/pg --save-dev pnpm install @prisma/client @prisma/adapter-pg dotenv pg -
Initialize Prisma:
pnpm dlx prisma init --db --output ../src/generated/prisma -
Configure Database URL: In
api/.env, add your PostgreSQL connection string (e.g., from Neon, Supabase, or local).DATABASE_URL="postgresql://user:password@host:5432/db?sslmode=require" DIRECT_URL="postgresql://user:password@host:5432/db?sslmode=require" # Note: For Cloudflare Workers, use the connection pool URL for DATABASE_URL if available, or use Prisma Accelerate. -
Define Schema: Open
prisma/schema.prismaand add the Contact model.generator client { provider = "prisma-client" output = "../src/generated/prisma" } datasource db { provider = "postgresql" } model Invoice { id String @id @default(cuid()) invoice String @unique paymentStatus PaymentStatus @default(Pending) totalAmount Float paymentMethod String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum PaymentStatus { Paid Pending Failed } -
Run Migrations:
pnpm dlx prisma migrate dev --name init -
Generate Client:
pnpm dlx prisma generateCreate Prisma global Instance
// import { PrismaClient } from "../app/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaClient } from "../generated/prisma/client.js"; import "dotenv/config"; const globalForPrisma = global as unknown as { prisma: PrismaClient; }; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL, }); const prisma = globalForPrisma.prisma || new PrismaClient({ adapter, }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; export default prisma; -
Update Hono Handler: Update
src/index.tsto save data to the database.import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; // import db from "./lib/db.js"; import prisma from "./lib/db.js"; // import type { PrismaClient } from "./generated/prisma/client.js"; // type ContextWithPrisma = { // Variables: { // prisma: PrismaClient; // }; // }; const app = new Hono(); // const app = new Hono<ContextWithPrisma>(); app.use("/*", cors()); app.get("/", (c) => { return c.json({ message: "Hello World", }); }); app.get("/invoices", async (c) => { // console.log(prisma); // const invoices: any = []; const invoices = await prisma.invoice.findMany(); return c.json(invoices); }); app.post("/invoices", async (c) => { const data = await c.req.json(); const index = await prisma.invoice.count({ orderBy: { createdAt: "desc", }, }); const stringIndex = (index + 1).toString().padStart(4, "000"); const invoiceNumber = `INV${stringIndex}`; console.log("INVOICE NUMBER", invoiceNumber); const payload = { invoice: invoiceNumber, totalAmount: data.amount, paymentMethod: data.paymentMethod, }; console.log("PAYLOAD", payload); const newInvoice = await prisma.invoice.create({ data: { invoice: payload.invoice, totalAmount: Number(payload.totalAmount), paymentMethod: payload.paymentMethod, }, }); console.log("RECEIVED DATA", newInvoice); return c.json(data); }); serve( { fetch: app.fetch, port: 8787, }, (info) => { console.log(`Server is running on http://localhost:${info.port}`); } );
CLONE A STARTER HERE :
(https://github.com/MUKE-coder/nhpc-invoice-starterkit)
Step 5: Add Zod Validation
Now we'll ensure the data sent to the API is valid.
-
Install Zod:
pnpm add zod @hono/zod-validator -
Create a Schema and Validator: Update
src/index.ts.// src/index.ts import { z } from "zod"; import { zValidator } from "@hono/zod-validator"; // ... imports const contactSchema = z.object({ name: z.string().min(2), phone: z.string().min(10), }); app.post( "/contacts", zValidator("json", contactSchema), // Middleware validates the body async (c) => { const body = c.req.valid("json"); // Typed body! // ... prisma create logic using 'body' } );
Step 6: Add Scalar Docs (OpenAPI)
Finally, let's document our API.
-
Install Dependencies:
pnpm add @scalar/hono-api-reference @hono/zod-openapi -
Update App to use OpenAPI: We need to switch from standard
HonotoOpenAPIHonoand define our routes usingcreateRoute.// src/index.ts import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; import { apiReference } from "@scalar/hono-api-reference"; import { cors } from "hono/cors"; // ... prisma imports const app = new OpenAPIHono(); app.use("/*", cors()); const contactSchema = z.object({ name: z.string().min(2).openapi({ example: "John Doe" }), phone: z.string().min(10).openapi({ example: "1234567890" }), }); const route = createRoute({ method: "post", path: "/contacts", request: { body: { content: { "application/json": { schema: contactSchema, }, }, }, }, responses: { 200: { content: { "application/json": { schema: z.object({ message: z.string(), contact: contactSchema, }), }, }, description: "Contact created successfully", }, }, }); app.openapi(route, async (c) => { const body = c.req.valid("json"); // ... prisma logic return c.json({ message: "Created", contact: body }); // Simplified for example }); // Serve the docs app.doc("/doc", { openapi: "3.0.0", info: { version: "1.0.0", title: "My API", }, }); app.get( "/reference", apiReference({ spec: { url: "/doc", }, }) ); export default app; -
View Docs: Run the server and visit
http://localhost:8000/reference. You will see interactive API documentation generated by Scalar!

