├── .gitignore ├── .npmrc ├── README.md ├── app ├── entry.server.jsx ├── movie-link.jsx ├── root.jsx └── routes │ ├── _index.jsx │ ├── all-movies[.json].js │ ├── movie.$id.jsx │ └── search.jsx ├── migrations ├── 0001_schema.sql ├── 0002_movies.sql └── 0003_fts_movies.sql ├── package-lock.json ├── package.json ├── public ├── _headers ├── _routes.json └── favicon.ico ├── remix.config.js ├── server.ts └── wrangler.example.toml /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /functions/\[\[path\]\].js 5 | /functions/\[\[path\]\].js.map 6 | /functions/metafile.* 7 | /functions/version.txt 8 | /public/build 9 | .dev.vars 10 | /.wrangler 11 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Movies Example 2 | 3 | This movie database serves as an example for various data loading strategies in Remix. 4 | 5 | [Watch the "Remix Singles" on YouTube](https://www.youtube.com/playlist?list=PLXoynULbYuEApkwAGZ7U7LmL-BDHloB0l) 6 | 7 | ## Local Development 8 | 9 | To get started create a `wrangler.toml` configuration file, which will provide information like our database name. 10 | 11 | ```sh 12 | cp wrangler.example.toml wrangler.toml 13 | ``` 14 | 15 | You'll notice that the `name` and `database_name` are already filled in. [Deploying to Cloudflare](#deploying-to-cloudflare) shows you how to create a Pages project and D1 database, which you can name these whatever you want. 16 | 17 | To create and seed your local database, run the following. There is a lot of data, so this may take a little while. 18 | 19 | ```sh 20 | npm run db:migrate -- --local 21 | ``` 22 | 23 | You can now start the app locally. 24 | 25 | ```sh 26 | npm run dev 27 | ``` 28 | 29 | ## Deploying to Cloudflare 30 | 31 | ### Create/log in to your Cloudflare account 32 | 33 | The deployed version of this application uses [Cloudflare Pages](https://developers.cloudflare.com/pages/) and [Cloudflare D1](https://developers.cloudflare.com/d1/). In order to create and use these resources, you'll first need to log in to your Cloudflare account. 34 | 35 | If you don't have an account this command will take you to a page where you can create one for free. 36 | 37 | ```sh 38 | npx wrangler login 39 | ``` 40 | 41 | ### Create a D1 database 42 | 43 | First we need to provision a new D1 database. Be sure to copy the `database_id` returned and add it to your `wrangler.toml`. 44 | 45 | ```sh 46 | npx wrangler d1 create remix-movies-db 47 | ``` 48 | 49 | To setup and seed the database, run the following 50 | 51 | > [!WARNING] 52 | > D1 is still in public beta (as of the making of this example app), and we're trying to seed 53 | > _a lot_ of data, so this migration may fail! You might have to try running it a few times. 54 | 55 | ```sh 56 | npm run db:migrate 57 | ``` 58 | 59 | ### Create a Cloudflare Pages project 60 | 61 | Run the following to setup a new Cloudflare Pages project 62 | 63 | ```sh 64 | npx wrangler pages project create remix-movies 65 | ``` 66 | 67 | Before you deploy your application, you'll need to bind your D1 database to your Pages Function. 68 | 69 | Select your Pages project > **Settings > Functions > D1 database bindings > Add binding**. 70 | 71 | Name the **Variable name** "DB" and select `remix-movies-db` (or whatever you named your D1 database) from the dropdown and hit **Save**. 72 | 73 | > [!NOTE] 74 | > If you are deploying a preview (i.e. on a non-`main` branch) you'll need to setup a D1 binding under the **Preview** tab 75 | 76 | If you get stuck, visit the [Cloudflare docs for the full instructions](https://developers.cloudflare.com/pages/functions/bindings/#d1-databases) 77 | 78 | Now you're ready to deploy your site! 79 | 80 | ```sh 81 | npm run pages:deploy 82 | ``` 83 | -------------------------------------------------------------------------------- /app/entry.server.jsx: -------------------------------------------------------------------------------- 1 | // Added this custom entry.server.jsx file to add the server timing header 2 | import { RemixServer } from '@remix-run/react' 3 | import isbot from 'isbot' 4 | import { renderToReadableStream } from 'react-dom/server' 5 | 6 | const ABORT_DELAY = 5000 7 | 8 | const handleRequest = async ( 9 | request, 10 | responseStatusCode, 11 | responseHeaders, 12 | remixContext, 13 | ) => { 14 | let didError = false 15 | 16 | let start = Date.now() 17 | const stream = await renderToReadableStream( 18 | , 23 | { 24 | onError: (error) => { 25 | console.log('Caught an error') 26 | didError = true 27 | console.error(error) 28 | 29 | // You can also log crash/error report 30 | }, 31 | signal: AbortSignal.timeout(ABORT_DELAY), 32 | }, 33 | ) 34 | 35 | if (isbot(request.headers.get('user-agent'))) { 36 | await stream.allReady 37 | } 38 | 39 | let time = Date.now() - start 40 | responseHeaders.append('Server-Timing', `shell-render;dur=${time}`) 41 | responseHeaders.set('Transfer-Encoding', 'chunked') 42 | responseHeaders.set('Content-Type', 'text/html') 43 | 44 | return new Response(stream, { 45 | headers: responseHeaders, 46 | status: didError ? 500 : responseStatusCode, 47 | }) 48 | } 49 | 50 | export default handleRequest 51 | -------------------------------------------------------------------------------- /app/movie-link.jsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@remix-run/react' 2 | import { useEffect, useState } from 'react' 3 | 4 | export function MovieLink({ movie }) { 5 | let [prefetch, setPrefetch] = useState('intent') 6 | 7 | // Don't prefetch cached movies 8 | useEffect(() => { 9 | if (sessionStorage.getItem(`movie-${movie.id}`)) { 10 | setPrefetch('none') 11 | } 12 | }) 13 | 14 | let prefetchImage = () => { 15 | if (prefetch === 'none') return 16 | let img = new Image() 17 | img.src = movie.thumbnail 18 | } 19 | 20 | return ( 21 | 27 | {movie.title} 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /app/root.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Links, 3 | LiveReload, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from '@remix-run/react' 9 | import { Search } from './routes/search' 10 | 11 | export default function App() { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 29 |
35 |
42 |

Movies!

43 |
44 | Code •{' '} 45 | 46 | YouTube Videos 47 | {' '} 48 | • Remix Docs 49 |
50 | 51 |
52 | 53 |
54 |
55 | 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /app/routes/_index.jsx: -------------------------------------------------------------------------------- 1 | import { defer, Await, useLoaderData } from '@remix-run/react' 2 | import { Suspense } from 'react' 3 | import { MovieLink } from '../movie-link' 4 | 5 | export async function loader({ context: { env } }) { 6 | // use defer to unblock this DB query from the first byte 7 | // - speeds up TTFB 8 | // - speeds up FCP, LCP too because the browser can start downloading the 9 | // assets in parallel with the server side DB query 10 | return defer({ 11 | query: env.DB.prepare( 12 | `SELECT * FROM movies WHERE thumbnail != '' ORDER BY RANDOM() LIMIT 12`, 13 | ).all(), 14 | }) 15 | } 16 | 17 | // keep the home page data in memory so back clicks are instant and the data 18 | // doesn't change 19 | let cache 20 | export async function clientLoader({ serverLoader }) { 21 | if (cache) return { query: cache } 22 | 23 | let loaderData = await serverLoader() 24 | let query = await loaderData.query 25 | cache = query 26 | return { query } 27 | } 28 | 29 | // So that the client loader is called on initial load 30 | clientLoader.hydrate = true 31 | 32 | export default function Home() { 33 | let { query } = useLoaderData() 34 | 35 | return ( 36 | <> 37 | Data Loading in Remix 38 |

39 | Use Command + K to search. Here are a few random movies from the 40 | database 41 |

42 | }> 43 | 44 | {(query) => ( 45 |
    46 | {query.results.map((movie) => ( 47 |
  • 48 | 49 |
  • 50 | ))} 51 |
52 | )} 53 |
54 |
55 | 56 | ) 57 | } 58 | 59 | function Loading() { 60 | return ( 61 |
    62 | {Array.from({ length: 12 }).map((_, i) => ( 63 |
  • 64 | 65 |
  • 66 | ))} 67 |
68 | ) 69 | } 70 | 71 | function RandomLengthDashes() { 72 | return {'-'.repeat(Math.floor(Math.random() * 20))} 73 | } 74 | -------------------------------------------------------------------------------- /app/routes/all-movies[.json].js: -------------------------------------------------------------------------------- 1 | import { json } from '@remix-run/react' 2 | 3 | export async function loader({ context: { env } }) { 4 | let query = await env.DB.prepare( 5 | `SELECT id, title, extract, thumbnail FROM movies`, 6 | ).all() 7 | 8 | return json(query.results.reverse(), { 9 | headers: { 10 | 'Cache-Control': `public, max-age=${60 * 60 * 24}`, 11 | }, 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /app/routes/movie.$id.jsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from '@remix-run/react' 2 | import localforage from 'localforage' 3 | 4 | // Initial SSR and first visits will get fresh data from the DB on the server 5 | export async function loader({ params, context: { env } }) { 6 | let result = await env.DB.prepare('SELECT * FROM movies WHERE id = ?1') 7 | .bind(params.id) 8 | .first() 9 | return { movie: result } 10 | } 11 | 12 | // Cache movies individually in session storage in the browser for super fast 13 | // back/forward/revisits during the session, but will fetch fresh data 14 | // from the server if the user closes the tab and comes back later 15 | export async function clientLoader({ serverLoader, params }) { 16 | let cacheKey = `movie-${params.id}` 17 | let cache = sessionStorage.getItem(cacheKey) 18 | if (cache) return { movie: JSON.parse(cache) } 19 | 20 | let { movie } = await serverLoader() 21 | sessionStorage.setItem(cacheKey, JSON.stringify(movie)) 22 | return { movie } 23 | } 24 | 25 | export default function Movie() { 26 | let { movie } = useLoaderData() 27 | return ( 28 | <> 29 | {movie.title} 30 | 31 | 32 |
33 |
41 | movie poster 52 |
53 |
54 |

55 | {movie.title} ({movie.year}) 56 |

57 |

{movie.extract}

58 |
59 |
60 | 61 | ) 62 | } 63 | 64 | const X = 65 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAACy0lEQVR4nO3Z2XbjIBAE0Bp9OZ8+Dz2e40QCNdAb0PWSE8sWXTexreVPAVAKMoxcKCWxWCnloh/p9ZJSPlhIr2Y+ONf9ocyPfLFctQ0Z4DfI1d58dG4UN6ynJ52YJ4QnrMpTD0qlfgWr/oL9Uy9ex2q+bNs0Kzex3l68W97KvmExdrFJGDUZWLwdrR1eQR4We3dLhl2NjdWz05XSU6oHq3PXC6SzTidW/wJx01+kH2tomXAZqjCENbpYlIwOP4o1saRzJsaewJpb2CdzA89hTS9vmulRp7EkhrCIxJASWAjvJTSeEBYCe8kNJoeFkF6iI4liIZiX9DDSWAjjpTCGAhYCeOkMoIMFVy+1pdWw4OSluagmFsy9lJdTxoKhl/5C+lgw8TL5k5hgQbmM1T+vFRbUKhl+LBpiQaGY7ReILRZE65kfmphjQaikx0GcBxamqzqdHjhhYaKw34mUHxaGarueortiobO898UMbyywCbylEAILDIgAUoiChSZHDCkEwkIFJYwUYmHhRhNJCuGw8AUUTAoRsfBhCiaFxOpKPKx8G3KTH/Dc5KEDN3lQyk2e7nCTJ9Lc5CUabvLiHzd5WZmbvGHBTd4K4yZvsnIjXs/WyxBLqZihlxWWaiUrLxMsgzImXvpYZm8T/YWUsYy/sJSX08RyOXTUXFQNy/EkTm1pHSzvywNKAyhguUtRFMaQxgoiRZEeRhQrlBRFdCQ5rIBSFLnBhLDCSlGExpPACi5FkRhyGmsJKcr0qHNYC0lR5gaewFpOijIx9ijWolKU0eGHsJaWogxV6MfaQIrSX6QTaxspSmedHqzNpCg9pdhYW0pR2NV4WBtLUXgFGVjbS1EYNd+wDpGivJVtYh0lRWlWrmMdKEWpF69gHStFqdR/wjpcivKEcMNKqf+5UVztzafnJ8hV25D5ly+W6/5Q5nc+ONf3L5lqSkEpfwElAotXgK0AkQAAAABJRU5ErkJggg==' 66 | -------------------------------------------------------------------------------- /app/routes/search.jsx: -------------------------------------------------------------------------------- 1 | import { useFetcher, useLocation } from '@remix-run/react' 2 | import localforage from 'localforage' 3 | import { useEffect, useRef, useState } from 'react' 4 | import { MovieLink } from '../movie-link' 5 | 6 | // Query the database on the server before the data is replicated to indexeddb 7 | export async function loader({ request, context: { env } }) { 8 | let q = new URL(request.url).searchParams.get('q') 9 | if (!q) return [] 10 | 11 | q = `"${q.replace(/"/g, '""')}"` 12 | 13 | let query = await env.DB.prepare( 14 | `SELECT id, title, extract FROM movies WHERE id IN ( 15 | SELECT rowid FROM fts_movies WHERE fts_movies MATCH ?1 16 | ) 17 | LIMIT 20`, 18 | ) 19 | .bind(q) 20 | .all() 21 | 22 | return query.results 23 | } 24 | 25 | // Cache the data in indexeddb for future searches 26 | export async function clientLoader({ serverLoader, request }) { 27 | // before data is stored in indexeddb, it hits the server to search 28 | if (!memory) { 29 | replicateMovies() 30 | return serverLoader() 31 | } 32 | 33 | // after it searches it searches the data locally 34 | let q = new URL(request.url).searchParams.get('q') 35 | if (!q) return [] 36 | 37 | let matches = [] 38 | for (let movie of memory) { 39 | if ( 40 | movie.title.toLowerCase().includes(q) || 41 | movie.extract.toLowerCase().includes(q) 42 | ) { 43 | matches.push(movie) 44 | } 45 | if (matches.length >= 20) break 46 | } 47 | return matches 48 | } 49 | 50 | let memory 51 | let replicateMovies = async () => { 52 | replicateMovies = () => {} 53 | let cached = await localforage.getItem('all-movies') 54 | if (cached) { 55 | memory = cached 56 | return 57 | } 58 | 59 | let response = await fetch('/all-movies.json') 60 | let movies = await response.json() 61 | localforage.setItem('all-movies', movies) 62 | memory = movies 63 | } 64 | 65 | // This is NOT an example of a production ready component, there's just enough 66 | // to simulate a search modal but it is not accessible enough, it's recommended 67 | // you use a modal from a library like React Aria, etc. 68 | export function Search() { 69 | let [show, setShow] = useState(false) 70 | let ref = useRef() 71 | 72 | let location = useLocation() 73 | let search = useFetcher() 74 | 75 | useEffect(() => { 76 | if (show) { 77 | ref.current.select() 78 | } 79 | }, [show]) 80 | 81 | useEffect(() => { 82 | setShow(false) 83 | }, [location]) 84 | 85 | // bind command + k 86 | useEffect(() => { 87 | let listener = (event) => { 88 | if ((event.metaKey || event.ctrlKey) && event.key === 'k') { 89 | event.preventDefault() 90 | setShow(true) 91 | } 92 | } 93 | window.addEventListener('keydown', listener) 94 | return () => window.removeEventListener('keydown', listener) 95 | }, []) 96 | 97 | return ( 98 | <> 99 | 106 |
{ 108 | setShow(false) 109 | }} 110 | hidden={!show} 111 | style={{ 112 | position: 'fixed', 113 | top: 0, 114 | left: 0, 115 | width: '100vw', 116 | height: '100vw', 117 | margin: 'auto', 118 | background: 'hsla(0, 100%, 100%, 0.9)', 119 | zIndex: 100, 120 | overflow: 'hidden', 121 | }} 122 | > 123 |
{ 135 | event.stopPropagation() 136 | }} 137 | onKeyDown={(event) => { 138 | if (event.key === 'Escape') { 139 | setShow(false) 140 | } 141 | }} 142 | > 143 | 144 | { 150 | if ( 151 | event.key === 'Escape' && 152 | event.currentTarget.value === '' 153 | ) { 154 | setShow(false) 155 | } else { 156 | event.stopPropagation() 157 | } 158 | }} 159 | onChange={(event) => { 160 | search.submit(event.currentTarget.form) 161 | }} 162 | style={{ 163 | width: '100%', 164 | padding: '0.5rem 1rem', 165 | fontSize: '1.5em', 166 | position: 'sticky', 167 | top: 0, 168 | border: 'none', 169 | borderBottom: 'solid 1px #ccc', 170 | outline: 'none', 171 | }} 172 | /> 173 |
    174 | {search.data && 175 | search.data.map((movie, index) => ( 176 |
  • 177 |
    178 |

    179 | 180 |

    181 |

    182 | {movie.extract.slice(0, 200)}... 183 |

    184 |
    185 |
  • 186 | ))} 187 |
188 |
189 |
190 |
191 | 192 | ) 193 | } 194 | -------------------------------------------------------------------------------- /migrations/0001_schema.sql: -------------------------------------------------------------------------------- 1 | -- SQL script to create tables for movie database 2 | 3 | -- Table for movies 4 | CREATE TABLE movies ( 5 | id INTEGER PRIMARY KEY AUTOINCREMENT, 6 | title TEXT NOT NULL, 7 | year INTEGER, 8 | href TEXT, 9 | extract TEXT, 10 | thumbnail TEXT, 11 | thumbnail_width INTEGER, 12 | thumbnail_height INTEGER 13 | ); 14 | 15 | -- Table for cast members 16 | CREATE TABLE cast_members ( 17 | id INTEGER PRIMARY KEY AUTOINCREMENT, 18 | name TEXT NOT NULL UNIQUE 19 | ); 20 | 21 | -- Linking table for movies and cast members 22 | CREATE TABLE movie_cast ( 23 | movie_id INTEGER, 24 | cast_id INTEGER, 25 | FOREIGN KEY (movie_id) REFERENCES movies(id), 26 | FOREIGN KEY (cast_id) REFERENCES cast_members(id), 27 | PRIMARY KEY (movie_id, cast_id) 28 | ); 29 | 30 | -- Table for genres 31 | CREATE TABLE genres ( 32 | id INTEGER PRIMARY KEY AUTOINCREMENT, 33 | name TEXT NOT NULL UNIQUE 34 | ); 35 | 36 | -- Linking table for movies and genres 37 | CREATE TABLE movie_genres ( 38 | movie_id INTEGER, 39 | genre_id INTEGER, 40 | FOREIGN KEY (movie_id) REFERENCES movies(id), 41 | FOREIGN KEY (genre_id) REFERENCES genres(id), 42 | PRIMARY KEY (movie_id, genre_id) 43 | ); 44 | -------------------------------------------------------------------------------- /migrations/0003_fts_movies.sql: -------------------------------------------------------------------------------- 1 | CREATE VIRTUAL TABLE fts_movies USING fts5(title, extract); 2 | 3 | INSERT INTO fts_movies(rowid, title, extract) SELECT id, title, extract FROM movies; 4 | 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-movies", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix build", 8 | "dev": "remix dev --manual -c \"npm run start\"", 9 | "start": "wrangler pages dev --compatibility-date=2023-06-21 ./public", 10 | "typecheck": "tsc", 11 | "pages:deploy": "npm run build && npm run db:migrate && wrangler pages deploy ./public", 12 | "db:migrate": "wrangler d1 migrations apply remix-movies-db" 13 | }, 14 | "dependencies": { 15 | "@remix-run/cloudflare": "2.4.1", 16 | "@remix-run/cloudflare-pages": "2.4.1", 17 | "@remix-run/css-bundle": "2.4.1", 18 | "@remix-run/react": "2.4.1", 19 | "isbot": "^3.6.8", 20 | "localforage": "^1.10.0", 21 | "match-sorter": "^6.3.1", 22 | "react": "^18.3.0-canary-c5b937576-20231219", 23 | "react-dom": "^18.3.0-canary-c5b937576-20231219" 24 | }, 25 | "devDependencies": { 26 | "@cloudflare/workers-types": "^4.20230518.0", 27 | "@remix-run/dev": "2.4.1", 28 | "@remix-run/eslint-config": "2.4.1", 29 | "@types/react": "^18.2.20", 30 | "@types/react-dom": "^18.2.7", 31 | "eslint": "^8.38.0", 32 | "typescript": "^5.1.0", 33 | "wrangler": "^3.1.1" 34 | }, 35 | "engines": { 36 | "node": ">=18.0.0" 37 | }, 38 | "prettier": { 39 | "semi": false, 40 | "singleQuote": true, 41 | "trailingComma": "all" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/_headers: -------------------------------------------------------------------------------- 1 | /favicon.ico 2 | Cache-Control: public, max-age=3600, s-maxage=3600 3 | /build/* 4 | Cache-Control: public, max-age=31536000, immutable 5 | -------------------------------------------------------------------------------- /public/_routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "include": ["/*"], 4 | "exclude": ["/favicon.ico", "/build/*"] 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/example-movies/69ca00c07fddf97aa910814fe149538cb3ecb6c6/public/favicon.ico -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | export default { 3 | ignoredRouteFiles: ['**/.*'], 4 | server: './server.ts', 5 | serverBuildPath: 'functions/[[path]].js', 6 | serverConditions: ['workerd', 'worker', 'browser'], 7 | serverDependenciesToBundle: 'all', 8 | serverMainFields: ['browser', 'module', 'main'], 9 | serverMinify: true, 10 | serverModuleFormat: 'esm', 11 | serverPlatform: 'neutral', 12 | // appDirectory: "app", 13 | // assetsBuildDirectory: "public/build", 14 | // publicPath: "/build/", 15 | } 16 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import { logDevReady } from '@remix-run/cloudflare' 2 | import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages' 3 | import * as build from '@remix-run/dev/server-build' 4 | 5 | if (process.env.NODE_ENV === 'development') { 6 | logDevReady(build) 7 | } 8 | 9 | export const onRequest = createPagesFunctionHandler({ 10 | build, 11 | getLoadContext: (context) => { 12 | return { env: context.env } 13 | }, 14 | mode: build.mode, 15 | }) 16 | -------------------------------------------------------------------------------- /wrangler.example.toml: -------------------------------------------------------------------------------- 1 | compatibility_date = "2023-06-21" 2 | name = "remix-movies" 3 | 4 | [[d1_databases]] 5 | binding = "DB" 6 | database_id = "" 7 | database_name = "remix-movies-db" 8 | --------------------------------------------------------------------------------