diff --git a/remix-infinite-scroll/.eslintrc.cjs b/remix-infinite-scroll/.eslintrc.cjs
new file mode 100644
index 00000000..4f6f59ee
--- /dev/null
+++ b/remix-infinite-scroll/.eslintrc.cjs
@@ -0,0 +1,84 @@
+/**
+ * This is intended to be a basic starting point for linting in your app.
+ * It relies on recommended configs out of the box for simplicity, but you can
+ * and should modify this configuration to best suit your team's needs.
+ */
+
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ root: true,
+ parserOptions: {
+ ecmaVersion: "latest",
+ sourceType: "module",
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ env: {
+ browser: true,
+ commonjs: true,
+ es6: true,
+ },
+ ignorePatterns: ["!**/.server", "!**/.client"],
+
+ // Base config
+ extends: ["eslint:recommended"],
+
+ overrides: [
+ // React
+ {
+ files: ["**/*.{js,jsx,ts,tsx}"],
+ plugins: ["react", "jsx-a11y"],
+ extends: [
+ "plugin:react/recommended",
+ "plugin:react/jsx-runtime",
+ "plugin:react-hooks/recommended",
+ "plugin:jsx-a11y/recommended",
+ ],
+ settings: {
+ react: {
+ version: "detect",
+ },
+ formComponents: ["Form"],
+ linkComponents: [
+ { name: "Link", linkAttribute: "to" },
+ { name: "NavLink", linkAttribute: "to" },
+ ],
+ "import/resolver": {
+ typescript: {},
+ },
+ },
+ },
+
+ // Typescript
+ {
+ files: ["**/*.{ts,tsx}"],
+ plugins: ["@typescript-eslint", "import"],
+ parser: "@typescript-eslint/parser",
+ settings: {
+ "import/internal-regex": "^~/",
+ "import/resolver": {
+ node: {
+ extensions: [".ts", ".tsx"],
+ },
+ typescript: {
+ alwaysTryTypes: true,
+ },
+ },
+ },
+ extends: [
+ "plugin:@typescript-eslint/recommended",
+ "plugin:import/recommended",
+ "plugin:import/typescript",
+ ],
+ },
+
+ // Node
+ {
+ files: [".eslintrc.cjs"],
+ env: {
+ node: true,
+ },
+ },
+ ],
+};
diff --git a/remix-infinite-scroll/.gitignore b/remix-infinite-scroll/.gitignore
new file mode 100644
index 00000000..80ec311f
--- /dev/null
+++ b/remix-infinite-scroll/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+
+/.cache
+/build
+.env
diff --git a/remix-infinite-scroll/README.md b/remix-infinite-scroll/README.md
new file mode 100644
index 00000000..940a8295
--- /dev/null
+++ b/remix-infinite-scroll/README.md
@@ -0,0 +1,38 @@
+# Remix Infinite Query Example
+
+A demonstration of infinite scroll implementation in Remix using React Query, Loaders, and Defer for optimal performance and user experience.
+
+## Preview
+
+Open this example on [CodeSandbox](https://codesandbox.com):
+
+
+
+[](https://codesandbox.io/s/github/remix-run/examples/tree/main/remix-infinite-scroll)
+
+## Example
+
+A demonstration of infinite scroll implementation in Remix using React Query, Loaders, and Defer for optimal performance and user experience.
+
+๐ Project Structure
+
+```
+app/
+โโโ components/ # React components
+โ โโโ LoadingState.tsx
+โ โโโ PostCard.tsx
+โ โโโ PostSkeleton.tsx
+โ โโโ PostsList.tsx
+โโโ hooks/ # Custom hooks
+โ โโโ useIntersectionObserver.ts
+โ โโโ usePosts.ts
+โโโ routes/ # Remix routes
+โ โโโ _index.tsx
+โ โโโ api.posts.ts
+โโโ types/ # TypeScript types
+ โโโ post.ts
+```
+
+## Related Links
+
+[remix-loader-infinite-useQuery-bestpractice](https://github.com/Amateur0x1/remix-loader-infinite-useQuery-bestpractice)
\ No newline at end of file
diff --git a/remix-infinite-scroll/app/components/LoadingState.tsx b/remix-infinite-scroll/app/components/LoadingState.tsx
new file mode 100644
index 00000000..1d39aedf
--- /dev/null
+++ b/remix-infinite-scroll/app/components/LoadingState.tsx
@@ -0,0 +1,24 @@
+import { PostSkeleton } from './PostSkeleton';
+
+export function LoadingState() {
+ return (
+
+
้ฆๆฌกๅ ่ฝฝไธญ...
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+
+export function LoadingState2() {
+ return (
+
+
ไนๅ็ๅ ่ฝฝ...
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/remix-infinite-scroll/app/components/PostCard.tsx b/remix-infinite-scroll/app/components/PostCard.tsx
new file mode 100644
index 00000000..f769dd18
--- /dev/null
+++ b/remix-infinite-scroll/app/components/PostCard.tsx
@@ -0,0 +1,11 @@
+import type { Post } from '~/types/post';
+
+export function PostCard({ post }: { post: Post }) {
+ return (
+
+
{post.title}
+
{post.content}
+
By {post.author}
+
+ );
+}
\ No newline at end of file
diff --git a/remix-infinite-scroll/app/components/PostSkeleton.tsx b/remix-infinite-scroll/app/components/PostSkeleton.tsx
new file mode 100644
index 00000000..62dde767
--- /dev/null
+++ b/remix-infinite-scroll/app/components/PostSkeleton.tsx
@@ -0,0 +1,9 @@
+export function PostSkeleton() {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/remix-infinite-scroll/app/components/PostsList.tsx b/remix-infinite-scroll/app/components/PostsList.tsx
new file mode 100644
index 00000000..f0a49956
--- /dev/null
+++ b/remix-infinite-scroll/app/components/PostsList.tsx
@@ -0,0 +1,18 @@
+import type { Post } from '~/types/post';
+
+interface PostsListProps {
+ posts: Post[];
+}
+
+export function PostsList({ posts }: PostsListProps) {
+ return (
+
+ {posts.map((post) => (
+
+
{post.title}
+
{post.content}
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/remix-infinite-scroll/app/entry.client.tsx b/remix-infinite-scroll/app/entry.client.tsx
new file mode 100644
index 00000000..94d5dc0d
--- /dev/null
+++ b/remix-infinite-scroll/app/entry.client.tsx
@@ -0,0 +1,18 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` โจ
+ * For more information, see https://remix.run/file-conventions/entry.client
+ */
+
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+});
diff --git a/remix-infinite-scroll/app/entry.server.tsx b/remix-infinite-scroll/app/entry.server.tsx
new file mode 100644
index 00000000..45db3229
--- /dev/null
+++ b/remix-infinite-scroll/app/entry.server.tsx
@@ -0,0 +1,140 @@
+/**
+ * By default, Remix will handle generating the HTTP Response for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` โจ
+ * For more information, see https://remix.run/file-conventions/entry.server
+ */
+
+import { PassThrough } from "node:stream";
+
+import type { AppLoadContext, EntryContext } from "@remix-run/node";
+import { createReadableStreamFromReadable } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import { isbot } from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5_000;
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext,
+ // This is ignored so we can keep it in the template for visibility. Feel
+ // free to delete this parameter in your app if you're not using it!
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ loadContext: AppLoadContext
+) {
+ return isbot(request.headers.get("user-agent") || "")
+ ? handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ )
+ : handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ );
+}
+
+function handleBotRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ return new Promise((resolve, reject) => {
+ let shellRendered = false;
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ shellRendered = true;
+ const body = new PassThrough();
+ const stream = createReadableStreamFromReadable(body);
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(stream, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ // Log streaming rendering errors from inside the shell. Don't log
+ // errors encountered during initial shell rendering since they'll
+ // reject and get logged in handleDocumentRequest.
+ if (shellRendered) {
+ console.error(error);
+ }
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) {
+ return new Promise((resolve, reject) => {
+ let shellRendered = false;
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ shellRendered = true;
+ const body = new PassThrough();
+ const stream = createReadableStreamFromReadable(body);
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(stream, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ // Log streaming rendering errors from inside the shell. Don't log
+ // errors encountered during initial shell rendering since they'll
+ // reject and get logged in handleDocumentRequest.
+ if (shellRendered) {
+ console.error(error);
+ }
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/remix-infinite-scroll/app/hooks/useIntersectionObserver.ts b/remix-infinite-scroll/app/hooks/useIntersectionObserver.ts
new file mode 100644
index 00000000..c60e25c9
--- /dev/null
+++ b/remix-infinite-scroll/app/hooks/useIntersectionObserver.ts
@@ -0,0 +1,46 @@
+import { useEffect, RefObject, useCallback } from 'react';
+
+interface UseIntersectionObserverProps {
+ target: RefObject;
+ onIntersect: () => void;
+ enabled?: boolean;
+ threshold?: number | number[];
+ rootMargin?: string;
+ root?: Element | null;
+}
+
+export function useIntersectionObserver({
+ target,
+ onIntersect,
+ enabled = true,
+ threshold = 0.1,
+ rootMargin = '0px',
+ root = null,
+}: UseIntersectionObserverProps) {
+ const handleIntersect = useCallback((entries: IntersectionObserverEntry[]) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ onIntersect();
+ }
+ });
+ }, [onIntersect]);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const element = target.current;
+ if (!element) return;
+
+ const observer = new IntersectionObserver(handleIntersect, {
+ threshold,
+ rootMargin,
+ root,
+ });
+
+ observer.observe(element);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [target, enabled, handleIntersect, threshold, rootMargin, root]);
+}
\ No newline at end of file
diff --git a/remix-infinite-scroll/app/hooks/usePosts.ts b/remix-infinite-scroll/app/hooks/usePosts.ts
new file mode 100644
index 00000000..fd8561f3
--- /dev/null
+++ b/remix-infinite-scroll/app/hooks/usePosts.ts
@@ -0,0 +1,34 @@
+import { useInfiniteQuery, type UseInfiniteQueryOptions } from '@tanstack/react-query';
+import type { InfiniteData } from '@tanstack/react-query';
+import type { Post } from '~/types/post';
+
+export type PostsResponse = {
+ posts: Post[];
+ nextPage: number | null;
+};
+
+export const fetchPosts = async ({ pageParam = 1 }: { pageParam?: unknown } = {}): Promise => {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ const response = await fetch(`/api/posts?page=${Number(pageParam)}&limit=10`);
+ if (!response.ok) {
+ throw new Error('Network response was not ok');
+ }
+ return response.json();
+};
+
+type UsePostsOptions = Partial>>;
+
+export function usePosts(options?: UsePostsOptions) {
+ return useInfiniteQuery({
+ queryKey: ['posts'],
+ queryFn: fetchPosts,
+ initialPageParam: 1,
+ getNextPageParam: (lastPage) => lastPage.nextPage,
+ retry: 3,
+ retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
+ staleTime: 1000 * 60 * 5,
+ gcTime: 1000 * 60 * 30,
+ ...options,
+ });
+}
\ No newline at end of file
diff --git a/remix-infinite-scroll/app/root.tsx b/remix-infinite-scroll/app/root.tsx
new file mode 100644
index 00000000..8490894e
--- /dev/null
+++ b/remix-infinite-scroll/app/root.tsx
@@ -0,0 +1,51 @@
+import {
+ Links,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+} from "@remix-run/react";
+import type { LinksFunction } from "@remix-run/node";
+import { QueryClientProvider } from '@tanstack/react-query';
+import { queryClient } from '~/utils/query-client';
+
+import "./tailwind.css";
+
+export const links: LinksFunction = () => [
+ { rel: "preconnect", href: "https://fonts.googleapis.com" },
+ {
+ rel: "preconnect",
+ href: "https://fonts.gstatic.com",
+ crossOrigin: "anonymous",
+ },
+ {
+ rel: "stylesheet",
+ href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
+ },
+];
+
+export function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/remix-infinite-scroll/app/routes/_index.tsx b/remix-infinite-scroll/app/routes/_index.tsx
new file mode 100644
index 00000000..bb023a95
--- /dev/null
+++ b/remix-infinite-scroll/app/routes/_index.tsx
@@ -0,0 +1,76 @@
+import { defer } from '@remix-run/node';
+import type { LoaderFunctionArgs } from '@remix-run/node';
+import { Await, useLoaderData } from '@remix-run/react';
+import { Suspense, useCallback, useRef } from 'react';
+import { LoadingState, LoadingState2 } from '~/components/LoadingState';
+import { PostsList } from '~/components/PostsList';
+import { getPosts } from '~/utils/api';
+import type { Post } from '~/types/post';
+import { usePosts, type PostsResponse } from '~/hooks/usePosts';
+import { useIntersectionObserver } from '~/hooks/useIntersectionObserver';
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const postsPromise = getPosts();
+
+ return defer({
+ posts: postsPromise
+ });
+}
+
+export default function Index() {
+ const { posts: initialPostsPromise } = useLoaderData();
+ const loadMoreRef = useRef(null);
+
+ return (
+
+
Blog Posts
+
}>
+
+ {(initialPosts: Post[]) => {
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage
+ } = usePosts({
+ initialData: {
+ pages: [{ posts: initialPosts, nextPage: 2 }],
+ pageParams: [1]
+ }
+ });
+
+ useIntersectionObserver({
+ target: loadMoreRef,
+ onIntersect: () => {
+ if (!isFetchingNextPage) {
+ fetchNextPage();
+ }
+ },
+ enabled: !!hasNextPage
+ });
+
+ const posts = data?.pages?.flatMap((page) => page.posts) ?? [];
+
+ return (
+ <>
+
+ {hasNextPage && (
+
+ {isFetchingNextPage ? (
+
+ ) : (
+ Load more posts...
+ )}
+
+ )}
+ >
+ );
+ }}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/remix-infinite-scroll/app/routes/api.posts.ts b/remix-infinite-scroll/app/routes/api.posts.ts
new file mode 100644
index 00000000..b1ae30bf
--- /dev/null
+++ b/remix-infinite-scroll/app/routes/api.posts.ts
@@ -0,0 +1,25 @@
+import { json } from '@remix-run/node';
+import type { LoaderFunctionArgs } from '@remix-run/node';
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const url = new URL(request.url);
+ const page = Number(url.searchParams.get('page')) || 1;
+ const limit = Number(url.searchParams.get('limit')) || 10;
+
+ // ๆจกๆไปๆฐๆฎๅบ่ทๅๆฐๆฎ
+ const start = (page - 1) * limit;
+ const posts = Array.from({ length: limit }, (_, i) => ({
+ id: start + i + 1,
+ title: `Post ${start + i + 1}`,
+ content: `Content ${start + i + 1}`,
+ author: 'Author'
+ }));
+
+ // ๆจกๆๆปๅ
ฑๆ50็ฏๆ็ซ
+ const hasMore = start + limit < 50;
+
+ return json({
+ posts,
+ nextPage: hasMore ? page + 1 : null
+ });
+}
\ No newline at end of file
diff --git a/remix-infinite-scroll/app/tailwind.css b/remix-infinite-scroll/app/tailwind.css
new file mode 100644
index 00000000..303fe158
--- /dev/null
+++ b/remix-infinite-scroll/app/tailwind.css
@@ -0,0 +1,12 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html,
+body {
+ @apply bg-white dark:bg-gray-950;
+
+ @media (prefers-color-scheme: dark) {
+ color-scheme: dark;
+ }
+}
diff --git a/remix-infinite-scroll/app/types/post.ts b/remix-infinite-scroll/app/types/post.ts
new file mode 100644
index 00000000..a541a4a7
--- /dev/null
+++ b/remix-infinite-scroll/app/types/post.ts
@@ -0,0 +1,6 @@
+export interface Post {
+ id: number;
+ title: string;
+ content: string;
+ author: string;
+}
\ No newline at end of file
diff --git a/remix-infinite-scroll/app/utils/api.ts b/remix-infinite-scroll/app/utils/api.ts
new file mode 100644
index 00000000..125b22cc
--- /dev/null
+++ b/remix-infinite-scroll/app/utils/api.ts
@@ -0,0 +1,17 @@
+import type { Post } from '~/types/post';
+
+// Simulate API delay
+const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+// Mock data
+const posts: Post[] = Array.from({ length: 10 }, (_, i) => ({
+ id: i + 1,
+ title: `Post ${i + 1}`,
+ content: `This is the content for post ${i + 1}`,
+ author: `Author ${i + 1}`
+}));
+
+export async function getPosts(): Promise {
+ await delay(2000); // Simulate network delay
+ return posts;
+}
\ No newline at end of file
diff --git a/remix-infinite-scroll/app/utils/query-client.ts b/remix-infinite-scroll/app/utils/query-client.ts
new file mode 100644
index 00000000..f24ea297
--- /dev/null
+++ b/remix-infinite-scroll/app/utils/query-client.ts
@@ -0,0 +1,10 @@
+import { QueryClient } from '@tanstack/react-query';
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 10000,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
\ No newline at end of file
diff --git a/remix-infinite-scroll/package.json b/remix-infinite-scroll/package.json
new file mode 100644
index 00000000..4ff0adf3
--- /dev/null
+++ b/remix-infinite-scroll/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "my-remix-app",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "build": "remix vite:build",
+ "dev": "remix vite:dev",
+ "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
+ "start": "remix-serve ./build/server/index.js",
+ "typecheck": "tsc"
+ },
+ "dependencies": {
+ "@remix-run/node": "^2.15.2",
+ "@remix-run/react": "^2.15.2",
+ "@remix-run/serve": "^2.15.2",
+ "@tanstack/react-query": "^5.28.4",
+ "isbot": "^4.1.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@remix-run/dev": "^2.15.2",
+ "@types/react": "^18.2.20",
+ "@types/react-dom": "^18.2.7",
+ "@typescript-eslint/eslint-plugin": "^6.7.4",
+ "@typescript-eslint/parser": "^6.7.4",
+ "autoprefixer": "^10.4.19",
+ "eslint": "^8.38.0",
+ "eslint-import-resolver-typescript": "^3.6.1",
+ "eslint-plugin-import": "^2.28.1",
+ "eslint-plugin-jsx-a11y": "^6.7.1",
+ "eslint-plugin-react": "^7.33.2",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "postcss": "^8.4.38",
+ "tailwindcss": "^3.4.4",
+ "typescript": "^5.1.6",
+ "vite": "^5.1.0",
+ "vite-tsconfig-paths": "^4.2.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+}
\ No newline at end of file
diff --git a/remix-infinite-scroll/postcss.config.js b/remix-infinite-scroll/postcss.config.js
new file mode 100644
index 00000000..2aa7205d
--- /dev/null
+++ b/remix-infinite-scroll/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/remix-infinite-scroll/public/favicon.ico b/remix-infinite-scroll/public/favicon.ico
new file mode 100644
index 00000000..8830cf68
Binary files /dev/null and b/remix-infinite-scroll/public/favicon.ico differ
diff --git a/remix-infinite-scroll/public/logo-dark.png b/remix-infinite-scroll/public/logo-dark.png
new file mode 100644
index 00000000..b24c7aee
Binary files /dev/null and b/remix-infinite-scroll/public/logo-dark.png differ
diff --git a/remix-infinite-scroll/public/logo-light.png b/remix-infinite-scroll/public/logo-light.png
new file mode 100644
index 00000000..4490ae79
Binary files /dev/null and b/remix-infinite-scroll/public/logo-light.png differ
diff --git a/remix-infinite-scroll/sandbox.config.json b/remix-infinite-scroll/sandbox.config.json
new file mode 100644
index 00000000..f92e0250
--- /dev/null
+++ b/remix-infinite-scroll/sandbox.config.json
@@ -0,0 +1,7 @@
+{
+ "hardReloadOnChange": true,
+ "template": "remix",
+ "container": {
+ "port": 3000
+ }
+}
diff --git a/remix-infinite-scroll/tailwind.config.ts b/remix-infinite-scroll/tailwind.config.ts
new file mode 100644
index 00000000..5f06ad4b
--- /dev/null
+++ b/remix-infinite-scroll/tailwind.config.ts
@@ -0,0 +1,22 @@
+import type { Config } from "tailwindcss";
+
+export default {
+ content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
+ theme: {
+ extend: {
+ fontFamily: {
+ sans: [
+ "Inter",
+ "ui-sans-serif",
+ "system-ui",
+ "sans-serif",
+ "Apple Color Emoji",
+ "Segoe UI Emoji",
+ "Segoe UI Symbol",
+ "Noto Color Emoji",
+ ],
+ },
+ },
+ },
+ plugins: [],
+} satisfies Config;
diff --git a/remix-infinite-scroll/tsconfig.json b/remix-infinite-scroll/tsconfig.json
new file mode 100644
index 00000000..9d87dd37
--- /dev/null
+++ b/remix-infinite-scroll/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ "**/.server/**/*.ts",
+ "**/.server/**/*.tsx",
+ "**/.client/**/*.ts",
+ "**/.client/**/*.tsx"
+ ],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "types": ["@remix-run/node", "vite/client"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "target": "ES2022",
+ "strict": true,
+ "allowJs": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+
+ // Vite takes care of building everything, not tsc.
+ "noEmit": true
+ }
+}
diff --git a/remix-infinite-scroll/vite.config.ts b/remix-infinite-scroll/vite.config.ts
new file mode 100644
index 00000000..e4e8cefc
--- /dev/null
+++ b/remix-infinite-scroll/vite.config.ts
@@ -0,0 +1,24 @@
+import { vitePlugin as remix } from "@remix-run/dev";
+import { defineConfig } from "vite";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+declare module "@remix-run/node" {
+ interface Future {
+ v3_singleFetch: true;
+ }
+}
+
+export default defineConfig({
+ plugins: [
+ remix({
+ future: {
+ v3_fetcherPersist: true,
+ v3_relativeSplatPath: true,
+ v3_throwAbortReason: true,
+ v3_singleFetch: true,
+ v3_lazyRouteDiscovery: true,
+ },
+ }),
+ tsconfigPaths(),
+ ],
+});