├── .gitignore ├── postcss.config.js ├── src ├── app │ ├── favicon.ico │ ├── layout.tsx │ ├── globals.css │ └── page.tsx ├── types │ └── index.ts ├── lib │ └── utils.ts ├── actions │ └── fetch-products.ts └── components │ ├── ui │ ├── spinner.tsx │ ├── button.tsx │ └── card.tsx │ ├── beers.tsx │ └── load-more.tsx ├── next.config.js ├── next-env.d.ts ├── components.json ├── README.md ├── public ├── vercel.svg └── next.svg ├── tsconfig.json ├── package.json ├── LICENSE └── tailwind.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | node_modules/ 3 | build/ 4 | dist/ 5 | .env -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rajeshdavidbabu/infinite-scroll-server-actions/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface Beer { 2 | id: number; 3 | name: string; 4 | tagline: string; 5 | image_url: string; 6 | } 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | serverActions: true, 5 | }, 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # infinite-scroll-server-actions 2 | 3 | A simple infinite scroll implemented using Next.13 server actions and shadcn-ui 4 | 5 | Screenshot 2023-07-27 at 17 06 21 6 | 7 | ## Implementation 8 | 9 | Video link: https://www.youtube.com/watch?v=UWwUWpcFEBM 10 | 11 | ## installation 12 | 13 | ``` 14 | npm install 15 | npm run dev 16 | ``` 17 | -------------------------------------------------------------------------------- /src/actions/fetch-products.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { Beer } from "@/types"; 3 | 4 | export async function fetchBeers(page: number) { 5 | const perPage = 24; 6 | const apiUrl = `https://api.punkapi.com/v2/beers?page=${page}&per_page=${perPage}`; 7 | try { 8 | const response = await fetch(apiUrl); 9 | const data = await response.json(); 10 | return data as Beer[]; 11 | } catch (error) { 12 | console.error("Error fetching data:", error); 13 | return null; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/ui/spinner.tsx: -------------------------------------------------------------------------------- 1 | export function Spinner() { 2 | return ( 3 |
7 | 8 | Loading... 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "Create Next App", 9 | description: "Generated by create next app", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | .dark { 12 | --foreground-rgb: 255, 255, 255; 13 | --background-start-rgb: 0, 0, 0; 14 | --background-end-rgb: 0, 0, 0; 15 | } 16 | 17 | body { 18 | color: rgb(var(--foreground-rgb)); 19 | background: linear-gradient( 20 | to bottom, 21 | transparent, 22 | rgb(var(--background-end-rgb)) 23 | ) 24 | rgb(var(--background-start-rgb)); 25 | } 26 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { fetchBeers } from "@/actions/fetch-products"; 2 | import { LoadMore } from "@/components/load-more"; 3 | import { Beers } from "@/components/beers"; 4 | 5 | const ProductsPage = async () => { 6 | const beers = await fetchBeers(1); 7 | 8 | return ( 9 |
10 |

Infinite Beers

11 |
12 | 13 | 14 |
15 |
16 | ); 17 | }; 18 | 19 | export default ProductsPage; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-shadcn-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-dropdown-menu": "^2.0.5", 13 | "@radix-ui/react-icons": "^1.3.0", 14 | "@radix-ui/react-slot": "^1.0.2", 15 | "@types/node": "20.4.2", 16 | "@types/react": "18.2.14", 17 | "@types/react-dom": "18.2.7", 18 | "autoprefixer": "10.4.14", 19 | "class-variance-authority": "^0.6.1", 20 | "clsx": "^1.2.1", 21 | "eslint": "8.44.0", 22 | "eslint-config-next": "13.4.9", 23 | "lucide-react": "^0.259.0", 24 | "moment-timezone": "^0.5.43", 25 | "next": "13.4.9", 26 | "next-themes": "^0.2.1", 27 | "postcss": "8.4.25", 28 | "react": "18.2.0", 29 | "react-dom": "18.2.0", 30 | "react-intersection-observer": "^9.5.2", 31 | "tailwind-merge": "^1.13.2", 32 | "tailwindcss": "3.3.2", 33 | "tailwindcss-animate": "^1.0.6", 34 | "typescript": "5.1.6" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Rajesh Babu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/beers.tsx: -------------------------------------------------------------------------------- 1 | import { Beer } from "@/types"; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardFooter, 7 | CardHeader, 8 | CardTitle, 9 | } from "@/components/ui/card"; 10 | 11 | export interface BeerProps { 12 | beers: Beer[] | null; 13 | } 14 | 15 | export function Beers({ beers }: BeerProps) { 16 | return ( 17 | <> 18 | {beers ? ( 19 | beers.map((beer) => ( 20 | 21 | 22 | {beer.name} 27 | 28 | 29 | {beer.name} 30 | {beer.tagline} 31 | 32 | 33 | )) 34 | ) : ( 35 |
No beers available !!
36 | )} 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/load-more.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useInView } from "react-intersection-observer"; 5 | import { Spinner } from "@/components/ui/spinner"; 6 | import { fetchBeers } from "@/actions/fetch-products"; 7 | import { Beer } from "@/types"; 8 | import { Beers } from "@/components/beers"; 9 | 10 | export function LoadMore() { 11 | const [beers, setBeers] = useState([]); 12 | const [page, setPage] = useState(1); 13 | 14 | const { ref, inView } = useInView(); 15 | 16 | const delay = (ms: number) => 17 | new Promise((resolve) => setTimeout(resolve, ms)); 18 | 19 | const loadMoreBeers = async () => { 20 | // Once the page 8 is reached repeat the process all over again. 21 | await delay(2000); 22 | const nextPage = (page % 7) + 1; 23 | const newProducts = (await fetchBeers(nextPage)) ?? []; 24 | setBeers((prevProducts: Beer[]) => [...prevProducts, ...newProducts]); 25 | setPage(nextPage); 26 | }; 27 | 28 | useEffect(() => { 29 | if (inView) { 30 | loadMoreBeers(); 31 | } 32 | }, [inView]); 33 | 34 | return ( 35 | <> 36 | 37 |
41 | 42 |
43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | "./pages/**/*.{ts,tsx}", 6 | "./components/**/*.{ts,tsx}", 7 | "./app/**/*.{ts,tsx}", 8 | "./src/**/*.{ts,tsx}", 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | padding: "2rem", 14 | screens: { 15 | "2xl": "1400px", 16 | }, 17 | }, 18 | extend: { 19 | colors: { 20 | border: "hsl(var(--border))", 21 | input: "hsl(var(--input))", 22 | ring: "hsl(var(--ring))", 23 | background: "hsl(var(--background))", 24 | foreground: "hsl(var(--foreground))", 25 | primary: { 26 | DEFAULT: "hsl(var(--primary))", 27 | foreground: "hsl(var(--primary-foreground))", 28 | }, 29 | secondary: { 30 | DEFAULT: "hsl(var(--secondary))", 31 | foreground: "hsl(var(--secondary-foreground))", 32 | }, 33 | destructive: { 34 | DEFAULT: "hsl(var(--destructive))", 35 | foreground: "hsl(var(--destructive-foreground))", 36 | }, 37 | muted: { 38 | DEFAULT: "hsl(var(--muted))", 39 | foreground: "hsl(var(--muted-foreground))", 40 | }, 41 | accent: { 42 | DEFAULT: "hsl(var(--accent))", 43 | foreground: "hsl(var(--accent-foreground))", 44 | }, 45 | popover: { 46 | DEFAULT: "hsl(var(--popover))", 47 | foreground: "hsl(var(--popover-foreground))", 48 | }, 49 | card: { 50 | DEFAULT: "hsl(var(--card))", 51 | foreground: "hsl(var(--card-foreground))", 52 | }, 53 | }, 54 | borderRadius: { 55 | lg: "var(--radius)", 56 | md: "calc(var(--radius) - 2px)", 57 | sm: "calc(var(--radius) - 4px)", 58 | }, 59 | keyframes: { 60 | "accordion-down": { 61 | from: { height: 0 }, 62 | to: { height: "var(--radix-accordion-content-height)" }, 63 | }, 64 | "accordion-up": { 65 | from: { height: "var(--radix-accordion-content-height)" }, 66 | to: { height: 0 }, 67 | }, 68 | gradient: { 69 | "0%": { 70 | backgroundPosition: "0%", 71 | }, 72 | "50%": { 73 | backgroundPosition: "100%", 74 | }, 75 | "100%": { 76 | backgroundPosition: "0%", 77 | }, 78 | }, 79 | }, 80 | backgroundSize: { 81 | gradient: "250%", 82 | }, 83 | animation: { 84 | "accordion-down": "accordion-down 0.2s ease-out", 85 | "accordion-up": "accordion-up 0.2s ease-out", 86 | gradient: "gradient 5s ease infinite alternate", 87 | }, 88 | }, 89 | }, 90 | plugins: [require("tailwindcss-animate")], 91 | }; 92 | --------------------------------------------------------------------------------