├── .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 |
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 |
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 |
--------------------------------------------------------------------------------