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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | //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; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | //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; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | //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> ); }; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | //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; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | //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; |
1 2 3 4 5 6 | //components\Spinner.tsx export const Spinner = () => { return ( <span className= "loading loading-spinner loading-lg" >Loading</span> ); }; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | //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; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | //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; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | //lib\actions.ts "use server" ; 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" ); }; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 | //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" ); } }; |
1 2 3 4 5 6 7 8 9 10 | //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; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | //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, ]; }; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | //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 } |
run C:\nextjs>npm run dev