Install requirements
Zod
Zod is a TypeScript-first schema declaration and validation library.
https://www.npmjs.com/package/zod
Prisma is an open-source next-generation ORM. It consists of the following parts: Prisma Client: Auto-generated and type-safe query builder
https://www.prisma.io
Install prisma/client
npm install @prisma/client
https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/generating-prisma-client
Generate Prisma Client with the following command:
npx prisma generate
Install prisma
npm install prisma --save-dev
https://www.prisma.io/docs/getting-started/quickstart
set up Prisma with the init command of the Prisma CLI:
npx prisma init --datasource-provider sqlite
to npx prisma init --datasource-provider postgres
Model data in the Prisma schema
prisma/schema.prisma
model Post {
id String @id @default(cuid())
title String
image String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Run a migration to create your database tables with Prisma Migrate
npx prisma migrate dev --name init
.env
DATABASE_URL="postgresql://postgres:admin@localhost:5432/postgresDB?schema=public"
https://www.prisma.io/docs/orm/prisma-client/queries/crud
https://nextjs.org/docs/app/building-your-application/routing/route-handlers
app\post\page.tsx
//app\post\page.tsx import PostTable from "@/components/table"; import { getDataPages } from "@/lib/data"; import Pagination from "@/components/pagination"; import Link from "next/link"; import { Suspense } from "react"; import { Spinner } from "@/components/spinner"; import Search from "@/components/search"; const Posts = async ({ searchParams, }: { searchParams?: { query?: string; page?: string; }; }) => { const query = searchParams?.query || ""; const currentPage = Number(searchParams?.page) || 1; const totalPages = await getDataPages(query); console.log(searchParams); console.log(query); console.log(currentPage); return ( <div className="max-w-screen-lg mx-auto py-14"> <h1 className="text-4xl font-bold">Nextjs Pagination and Search with Create and Upload Image | Postgresql Prisma</h1> <Link href="/create" className="py-3 px-6 bg-green-700 hover:bg-green-800 text-white" > New Post </Link> <div className="flex items-end justify-between m-12"> <h1 className="text-4xl font-bold">Latest Post</h1> <div><Search /></div> </div> <Suspense key={query + currentPage} fallback={<Spinner />}> <PostTable query={query} currentPage={currentPage} /> </Suspense> <div className="flex justify-center mt-4"> <Pagination totalPages={totalPages} /> </div> </div> ); }; export default Posts;components\table.tsx
//components\table.tsx import Image from "next/image"; import { formatDate } from "@/lib/utils"; import { getPosts } from "@/lib/data"; const PostTable = async ({ query, currentPage, }: { query: string; currentPage: number; }) => { const posts = await getPosts(query, currentPage); return ( <div className="grid md:grid-cols-3 gap-5 mt-10"> {posts.map((item) => ( <div key={item.id} className="max-w-sm border border-gray-200 rounded-md shadow"> <div className="relative aspect-video"> <Image src={`http://localhost:3000/assets/${item.image}`} alt={item.title} fill priority sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" className="rounded-t-md object-cover" /> </div> <div className="p-5"> <h1 className="text-2xl font-bold text-gray-900 truncate"> {item.title} </h1> </div> <div className="flex items-center justify-between"> <div className="py-3 text-sm text-white bg-blue-700 rounded-bl-md w-full text-center">{formatDate(item.createdAt.toString())}</div> </div> </div> ))} </div> ); }; export default PostTable;components\button.tsx
//components\button.tsx "use client"; import { useFormStatus } from "react-dom"; import { clsx } from "clsx"; export const SubmitButton = ({ label }: { label: string }) => { const { pending } = useFormStatus(); return ( <button className={clsx( "bg-blue-700 text-white w-full font-medium py-2.5 px-6 text-base rounded-sm hover:bg-blue-600", { "opacity-50 cursor-progress": pending, } )} type="submit" disabled={pending} > {label === "upload" ? ( <>{pending ? "Uploading..." : "Upload"}</> ) : ( <>{pending ? "Updating..." : "Update"}</> )} </button> ); };components\upload-form.tsx
//components\upload-form.tsx "use client"; import React from "react"; import { CreateData } from "@/lib/actions"; import { useFormState } from "react-dom"; import { SubmitButton } from "@/components/button"; const UploadForm = () => { const [state, formAction] = useFormState(CreateData, null); return ( <form action={formAction}> {/* Alert */} {state?.message ? ( <div className="p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-50" role="alert" > <div className="font-medium">{state?.message}</div> </div> ) : null} <div className="mb-4 pt-2"> <input type="text" name="title" className="py-2 px-4 rounded-sm border border-gray-400 w-full" placeholder="Title..." /> <div aria-live="polite" aria-atomic="true"> <p className="text-sm text-red-500 mt-2">{state?.error?.title}</p> </div> </div> <div className="mb-4 pt-2"> <input type="file" name="image" className="file:py-2 file:px-4 file:mr-4 file:rounded-sm file:border-0 file:bg-gray-200 hover:file:bg-gray-300 file:cursor-pointer border border-gray-400 w-full" /> <div aria-live="polite" aria-atomic="true"> <p className="text-sm text-red-500 mt-2">{state?.error?.image}</p> </div> </div> <div className="mb-4 pt-4"> <SubmitButton label="upload" /> </div> </form> ); }; export default UploadForm;components\search.tsx
//components\search.tsx "use client"; import { IoSearch } from "react-icons/io5"; import { useSearchParams, usePathname, useRouter } from "next/navigation"; import { useDebouncedCallback } from "use-debounce"; //npm i use-debounce --save https://www.npmjs.com/package/use-debounce const Search = () => { const searchParams = useSearchParams(); const pathname = usePathname(); const { replace } = useRouter(); const handleSearch = useDebouncedCallback((term: string) => { // console.log(term); const params = new URLSearchParams(searchParams); params.set("page", "1"); if (term) { params.set("query", term); } else { params.delete("query"); } replace(`${pathname}?${params.toString()}`); }, 300); return ( <div className="relative flex flex-1"> <input type="text" className="w-full border border-gray-200 py-2 pl-10 text-sm outline-2 rounded-sm" placeholder="Search..." onChange={(e) => handleSearch(e.target.value)} defaultValue={searchParams.get("query")?.toString()} /> <IoSearch className="absolute left-3 top-2 h-5 w-5 text-gray-500" /> </div> ); }; export default Search;components\Spinner.tsx
//components\Spinner.tsx export const Spinner = () => { return ( <span className="loading loading-spinner loading-lg">Loading</span> ); };components\pagination.tsx
//components\pagination.tsx "use client"; import Link from "next/link"; import { HiChevronLeft, HiChevronRight } from "react-icons/hi"; //npm install react-icons --save https://www.npmjs.com/package/react-icons import { usePathname, useSearchParams } from "next/navigation"; import { generatePagination } from "@/lib/utils"; import clsx from "clsx"; const Pagination = ({ totalPages }: { totalPages: number }) => { const pathname = usePathname(); const searchParams = useSearchParams(); const currentPage = Number(searchParams.get("page")) || 1; const createPageURL = (pageNumber: string | number) => { const params = new URLSearchParams(searchParams); params.set("page", pageNumber.toString()); return `${pathname}?${params.toString()}`; }; const allPages = generatePagination(currentPage, totalPages); const PaginationNumber = ({ page, href, position, isActive, }: { page: number | string; href: string; position?: "first" | "last" | "middle" | "single"; isActive: boolean; }) => { const className = clsx( "flex h-10 w-10 items-center justify-center text-sm border", { "rounded-l-sm": position === "first" || position === "single", "rounded-r-sm": position === "last" || position === "single", "z-10 bg-blue-100 border-blue-500 text-white": isActive, "hover:bg-gray-100": !isActive && position !== "middle", "text-gray-300 pointer-events-none": position === "middle", } ); return isActive && position === "middle" ? ( <div className={className}>{page}</div> ) : ( <Link href={href} className={className}> {page} </Link> ); }; const PaginationArrow = ({ href, direction, isDisabled, }: { href: string; direction: "left" | "right"; isDisabled?: boolean; }) => { const className = clsx( "flex h-10 w-10 items-center justify-center text-sm border", { "pointer-events-none text-gray-300": isDisabled, "hover:bg-gray-100": !isDisabled, "mr-2": direction === "left", "ml-2": direction === "right", } ); const icon = direction === "left" ? ( <HiChevronLeft size={20} /> ) : ( <HiChevronRight size={20} /> ); return isDisabled ? ( <div className={className}>{icon}</div> ) : ( <Link href={href} className={className}> {icon} </Link> ); }; return ( <div className="inline-flex"> <PaginationArrow direction="left" href={createPageURL(currentPage - 1)} isDisabled={currentPage <= 1} /> <div className="flex -space-x-px"> {allPages.map((page, index) => { let position: "first" | "last" | "single" | "middle" | undefined; if (index === 0) position = "first"; if (index === allPages.length - 1) position = "last"; if (allPages.length === 1) position = "single"; if (page === "...") position = "middle"; return ( <PaginationNumber key={index} href={createPageURL(page)} page={page} position={position} isActive={currentPage === page} /> ); })} </div> <PaginationArrow direction="right" href={createPageURL(currentPage + 1)} isDisabled={currentPage >= totalPages} /> </div> ); }; export default Pagination;app\create\page.tsx
//app\create\page.tsx import UploadForm from "@/components/upload-form"; const CreatePage = () => { return ( <div className="min-h-screen flex items-center justify-center bg-slate-100"> <div className="bg-white rounded-sm shadow p-8"> <h1 className="text-2xl font-bold mb-5">New Post</h1> <UploadForm /> </div> </div> ); }; export default CreatePage;lib\actions.ts
//lib\actions.ts "use server"; import { z } from "zod"; //https://www.npmjs.com/package/zod import { prisma } from "@/lib/prisma"; import { writeFile } from "fs/promises"; import path from "path"; import { redirect } from 'next/navigation' import { revalidatePath } from 'next/cache' const UploadSchema = z.object({ title: z.string().min(1), image: z .instanceof(File) .refine((file) => file.size > 0, { message: "Image is required" }) .refine((file) => file.size === 0 || file.type.startsWith("image/"), { message: "Only images are allowed", }) .refine((file) => file.size < 4000000, { message: "Image must less than 4MB", }), }); export const CreateData = async (prevState: unknown, formData: FormData) => { const validatedFields = UploadSchema.safeParse( Object.fromEntries(formData.entries()) ); if (!validatedFields.success) { return { error: validatedFields.error.flatten().fieldErrors, }; } const file = formData.get("image"); const { title } = validatedFields.data; try { const buffer = Buffer.from(await file.arrayBuffer()); const filename = file.name.replaceAll(" ", "_"); console.log(filename); await writeFile( path.join(process.cwd(), "public/assets/" + filename), buffer ); await prisma.post.create({ data: { title, image: filename, }, }); //return { message: "Success" }; } catch (error) { console.log("Error occured ", error); return { message: "Failed" }; } revalidatePath("/post"); redirect("/post"); };lib\data.ts
//lib\data.ts import { prisma } from "@/lib/prisma"; const ITEMS_PER_PAGE = 3; export const getPosts = async (query: string, currentPage: number) => { const offset = (currentPage - 1) * ITEMS_PER_PAGE; try { const posts = await prisma.post.findMany({ skip: offset, take: ITEMS_PER_PAGE, where: { OR: [ { title: { contains: query, mode: "insensitive", }, }, ], }, orderBy: { createdAt: "desc" }, }); return posts; } catch (error) { throw new Error("Failed to fetch contact data"); } }; export const getDataPages = async (query: string) => { try { const posts = await prisma.post.count({ where: { OR: [ { title: { contains: query, mode: "insensitive", }, }, ], }, orderBy: { createdAt: "desc" }, }); const totalPages = Math.ceil(Number(posts) / ITEMS_PER_PAGE); return totalPages; } catch (error) { throw new Error("Failed to fetch contact data"); } };lib\prisma.ts
//lib\prisma.ts import { PrismaClient } from "@prisma/client"; declare global { var prisma: PrismaClient | undefined; } export const prisma = globalThis.prisma || new PrismaClient(); if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma;lib\utils.ts
//lib\utils.ts export const formatDate = (dateStr: string) => { const date = new Date(dateStr); const formatter = new Intl.DateTimeFormat("id-ID", { dateStyle: "medium", }); return formatter.format(date); }; export const generatePagination = (currentPage: number, totalPages: number) => { if (totalPages <= 7) { return Array.from({ length: totalPages }, (_, i) => i + 1); } if (currentPage <= 3) { return [1, 2, 3, "...", totalPages - 1, totalPages]; } if (currentPage >= totalPages - 2) { return [1, 2, 3, "...", totalPages - 2, totalPages - 1, totalPages]; } return [ 1, "...", currentPage - 1, currentPage, currentPage + 1, "...", totalPages, ]; };prisma\schema.prisma
//prisma\schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Post { id String @id @default(cuid()) title String image String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }.env DATABASE_URL="postgresql://postgres:admin@localhost:5432/postgresDB?schema=public"
run C:\nextjs>npm run dev