├── .nvmrc
├── .npmrc
├── src
├── components
│ ├── Footer
│ │ ├── index.tsx
│ │ ├── Footer.tsx
│ │ └── Footer.test.tsx
│ ├── Header
│ │ ├── index.tsx
│ │ ├── Header.tsx
│ │ └── Header.test.tsx
│ ├── Navigation
│ │ ├── index.tsx
│ │ ├── Navigation.test.tsx
│ │ └── Navigation.tsx
│ └── ErrorBoundary
│ │ ├── index.tsx
│ │ ├── ErrorBoundary.tsx
│ │ └── ErrorBoundary.test.tsx
├── styles
│ └── tailwind.css
├── routes
│ ├── api
│ │ └── hello.tsx
│ ├── index.tsx
│ ├── about.tsx
│ ├── sentryLoader.tsx
│ ├── sentryFrontend.tsx
│ └── sentryAction.tsx
├── tests
│ ├── setup.ts
│ ├── hello.test.tsx
│ ├── _index.test.tsx
│ └── about.test.tsx
├── utils
│ ├── sentry.ts
│ ├── errors.ts
│ ├── sentry.test.ts
│ └── errors.test.ts
├── routes.ts
├── entry.client.tsx
├── e2e
│ └── example.spec.ts
├── root.tsx
└── entry.server.tsx
├── env.d.ts
├── eslint.config.js
├── public
├── favicon.ico
├── github.png
├── twitter.png
└── facebook.png
├── .env.example
├── plopfile.js
├── .prettierignore
├── .gitignore
├── vercel.json
├── .prettierrc
├── .all-contributorsrc
├── tsconfig.json
├── playwright.config.ts
├── license
├── .vscode
└── tasks.json
├── readme.md
├── vite.config.ts
├── .github
└── workflows
│ └── ci.yml
├── package.json
├── contributing.md
└── changelog.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | 22
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
2 | registry=https://registry.npmjs.org
3 |
--------------------------------------------------------------------------------
/src/components/Footer/index.tsx:
--------------------------------------------------------------------------------
1 | export {default} from "./Footer.js"
2 |
--------------------------------------------------------------------------------
/src/components/Header/index.tsx:
--------------------------------------------------------------------------------
1 | export {default} from "./Header.js"
2 |
--------------------------------------------------------------------------------
/src/components/Navigation/index.tsx:
--------------------------------------------------------------------------------
1 | export {default} from "./Navigation.js"
2 |
--------------------------------------------------------------------------------
/src/components/ErrorBoundary/index.tsx:
--------------------------------------------------------------------------------
1 | export {default} from "./ErrorBoundary.js"
2 |
--------------------------------------------------------------------------------
/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import config from "@bradgarropy/eslint-config"
2 | export default config
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bradgarropy/remix-starter/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bradgarropy/remix-starter/HEAD/public/github.png
--------------------------------------------------------------------------------
/public/twitter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bradgarropy/remix-starter/HEAD/public/twitter.png
--------------------------------------------------------------------------------
/public/facebook.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bradgarropy/remix-starter/HEAD/public/facebook.png
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | SENTRY_AUTH_TOKEN=sntrys_abc
2 | SENTRY_DSN=https://ingest.us.sentry.io
3 | SENTRY_ORG=bradgarropy
4 | SENTRY_PROJECT=remix-starter
5 |
--------------------------------------------------------------------------------
/plopfile.js:
--------------------------------------------------------------------------------
1 | const config = async plop => {
2 | await plop.load("@bradgarropy/plop-generator-remix-route")
3 | }
4 |
5 | export default config
6 |
--------------------------------------------------------------------------------
/src/styles/tailwind.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | nav > a.active {
4 | @apply underline decoration-purple-500 decoration-4 underline-offset-4;
5 | }
6 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 |
4 | # build
5 | /.cache
6 | /build
7 | /public/build
8 |
9 | # test
10 | /coverage
11 |
12 | # secrets
13 | .env*
14 |
--------------------------------------------------------------------------------
/src/routes/api/hello.tsx:
--------------------------------------------------------------------------------
1 | import {data} from "@remix-run/node"
2 |
3 | const loader = () => {
4 | return data({message: "world"}, {status: 200})
5 | }
6 |
7 | export {loader}
8 |
--------------------------------------------------------------------------------
/src/tests/setup.ts:
--------------------------------------------------------------------------------
1 | import "@testing-library/jest-dom/vitest"
2 |
3 | import {cleanup} from "@testing-library/react"
4 | import {afterEach} from "vitest"
5 |
6 | afterEach(() => {
7 | cleanup()
8 | })
9 |
--------------------------------------------------------------------------------
/src/utils/sentry.ts:
--------------------------------------------------------------------------------
1 | import pkg from "../../package.json"
2 |
3 | const createRelease = () => {
4 | const release = `${pkg.name}@${pkg.version}`
5 | return release
6 | }
7 |
8 | export {createRelease}
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 |
4 | # build
5 | /.cache
6 | /build
7 | /public/build
8 |
9 | # test
10 | /coverage
11 | /test-results
12 | /playwright-report
13 |
14 | # secrets
15 | .env
16 |
17 | # os
18 | .DS_Store
19 |
--------------------------------------------------------------------------------
/src/components/Footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | const Footer = () => {
2 | return (
3 |
6 | )
7 | }
8 |
9 | export default Footer
10 |
--------------------------------------------------------------------------------
/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | const Route = () => {
2 | return (
3 | <>
4 |
💿 remix starter | home
5 | Home
6 | >
7 | )
8 | }
9 |
10 | export default Route
11 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildCommand": "npm run build",
3 | "cleanUrls": true,
4 | "devCommand": "npm run dev",
5 | "framework": "remix",
6 | "installCommand": "npm install",
7 | "redirects": [],
8 | "trailingSlash": false
9 | }
10 |
--------------------------------------------------------------------------------
/src/routes/about.tsx:
--------------------------------------------------------------------------------
1 | const Route = () => {
2 | return (
3 | <>
4 | 💿 remix starter | about
5 | About
6 | >
7 | )
8 | }
9 |
10 | export default Route
11 |
--------------------------------------------------------------------------------
/src/utils/errors.ts:
--------------------------------------------------------------------------------
1 | const createErrorStack = (error: Error) => {
2 | if (!error.stack) {
3 | return ""
4 | }
5 |
6 | const shortStack = error.stack.split("\n").slice(0, 10).join("\n")
7 | return shortStack
8 | }
9 |
10 | export {createErrorStack}
11 |
--------------------------------------------------------------------------------
/src/tests/hello.test.tsx:
--------------------------------------------------------------------------------
1 | import {expect, test} from "vitest"
2 |
3 | import {loader} from "~/routes/api/hello"
4 |
5 | test("returns", async () => {
6 | const {data, init} = loader()
7 |
8 | expect(init).toMatchObject({status: 200})
9 | expect(data).toEqual({message: "world"})
10 | })
11 |
--------------------------------------------------------------------------------
/src/components/Footer/Footer.test.tsx:
--------------------------------------------------------------------------------
1 | import {render, screen} from "@testing-library/react"
2 | import {expect, test} from "vitest"
3 |
4 | import Footer from "~/components/Footer"
5 |
6 | test("renders", () => {
7 | render()
8 | expect(screen.getByText("Footer")).toBeInTheDocument()
9 | })
10 |
--------------------------------------------------------------------------------
/src/tests/_index.test.tsx:
--------------------------------------------------------------------------------
1 | import {render, screen} from "@testing-library/react"
2 | import {expect, test} from "vitest"
3 |
4 | import Route from "~/routes/index"
5 |
6 | test("renders", () => {
7 | render()
8 |
9 | expect(document.title).toEqual("💿 remix starter | home")
10 | expect(screen.getByText("Home")).toBeInTheDocument()
11 | })
12 |
--------------------------------------------------------------------------------
/src/tests/about.test.tsx:
--------------------------------------------------------------------------------
1 | import {render, screen} from "@testing-library/react"
2 | import {expect, test} from "vitest"
3 |
4 | import Route from "~/routes/about"
5 |
6 | test("renders", () => {
7 | render()
8 |
9 | expect(document.title).toEqual("💿 remix starter | about")
10 | expect(screen.getByText("About")).toBeInTheDocument()
11 | })
12 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 4,
4 | "useTabs": false,
5 | "semi": false,
6 | "singleQuote": false,
7 | "quoteProps": "consistent",
8 | "jsxSingleQuote": false,
9 | "trailingComma": "all",
10 | "bracketSpacing": false,
11 | "bracketSameLine": false,
12 | "arrowParens": "avoid",
13 | "endOfLine": "lf"
14 | }
15 |
--------------------------------------------------------------------------------
/src/routes/sentryLoader.tsx:
--------------------------------------------------------------------------------
1 | export const loader = () => {
2 | throw new Error("Sentry Loader Error")
3 | }
4 |
5 | const Route = () => {
6 | return (
7 | <>
8 | 💿 remix starter | sentry
9 | Sentry | Loader Error
10 | >
11 | )
12 | }
13 |
14 | export default Route
15 |
--------------------------------------------------------------------------------
/src/components/Header/Header.tsx:
--------------------------------------------------------------------------------
1 | import Navigation from "~/components/Navigation"
2 |
3 | const Header = () => {
4 | return (
5 |
6 | Remix Starter
7 |
8 |
9 | )
10 | }
11 |
12 | export default Header
13 |
--------------------------------------------------------------------------------
/src/utils/sentry.test.ts:
--------------------------------------------------------------------------------
1 | import {expect, test, vi} from "vitest"
2 |
3 | import {createRelease} from "~/utils/sentry"
4 |
5 | vi.mock("../../package.json", () => {
6 | const pkg = {
7 | name: "test",
8 | version: "1.2.3",
9 | }
10 |
11 | return {default: pkg}
12 | })
13 |
14 | test("creates release", () => {
15 | const release = createRelease()
16 | expect(release).toEqual("test@1.2.3")
17 | })
18 |
--------------------------------------------------------------------------------
/src/components/Header/Header.test.tsx:
--------------------------------------------------------------------------------
1 | import {render, screen} from "@testing-library/react"
2 | import {MemoryRouter} from "react-router"
3 | import {expect, test} from "vitest"
4 |
5 | import Header from "~/components/Header"
6 |
7 | test("renders", () => {
8 | render(
9 |
10 |
11 | ,
12 | )
13 |
14 | expect(screen.getByText("Remix Starter")).toBeInTheDocument()
15 | })
16 |
--------------------------------------------------------------------------------
/src/routes.ts:
--------------------------------------------------------------------------------
1 | import type {RouteConfig} from "@remix-run/route-config"
2 | import {index, route} from "@remix-run/route-config"
3 |
4 | const routes: RouteConfig = [
5 | index("./routes/index.tsx"),
6 | route("about", "./routes/about.tsx"),
7 | route("api/hello", "./routes/api/hello.tsx"),
8 | route("sentry/frontend", "./routes/sentryFrontend.tsx"),
9 | route("sentry/loader", "./routes/sentryLoader.tsx"),
10 | route("sentry/action", "./routes/sentryAction.tsx"),
11 | ]
12 |
13 | export default routes
14 |
--------------------------------------------------------------------------------
/src/components/Navigation/Navigation.test.tsx:
--------------------------------------------------------------------------------
1 | import {render, screen} from "@testing-library/react"
2 | import {MemoryRouter} from "react-router-dom"
3 | import {expect, test} from "vitest"
4 |
5 | import Navigation from "~/components/Navigation"
6 |
7 | test("renders", () => {
8 | render(
9 |
10 |
11 | ,
12 | )
13 |
14 | expect(screen.getByText("Home")).toBeInTheDocument()
15 | expect(screen.getByText("About")).toBeInTheDocument()
16 | })
17 |
--------------------------------------------------------------------------------
/src/routes/sentryFrontend.tsx:
--------------------------------------------------------------------------------
1 | const Route = () => {
2 | return (
3 | <>
4 | 💿 remix starter | sentry
5 | Sentry | Frontend Error
6 |
7 |
15 | >
16 | )
17 | }
18 |
19 | export default Route
20 |
--------------------------------------------------------------------------------
/src/utils/errors.test.ts:
--------------------------------------------------------------------------------
1 | import {expect, test} from "vitest"
2 |
3 | import {createErrorStack} from "~/utils/errors"
4 |
5 | test("creates error stack", () => {
6 | const error = new Error("Internal server error")
7 | const stack = createErrorStack(error)
8 |
9 | expect(stack).toContain("Error: Internal server error")
10 | })
11 |
12 | test("handles empty error", () => {
13 | const error = new Error()
14 | error.stack = undefined
15 |
16 | const stack = createErrorStack(error)
17 |
18 | expect(stack).toEqual("")
19 | })
20 |
--------------------------------------------------------------------------------
/src/routes/sentryAction.tsx:
--------------------------------------------------------------------------------
1 | export const action = () => {
2 | throw new Error("Sentry Action Error")
3 | }
4 |
5 | const Route = () => {
6 | return (
7 | <>
8 | 💿 remix starter | sentry
9 | Sentry | Action Error
10 |
11 |
15 | >
16 | )
17 | }
18 |
19 | export default Route
20 |
--------------------------------------------------------------------------------
/src/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import {RemixBrowser} from "@remix-run/react"
2 | import * as Sentry from "@sentry/remix"
3 | import {startTransition, StrictMode} from "react"
4 | import {hydrateRoot} from "react-dom/client"
5 |
6 | import {createRelease} from "~/utils/sentry"
7 |
8 | Sentry.init({
9 | dsn: import.meta.env.VITE_SENTRY_DSN,
10 | environment: import.meta.env.MODE,
11 | release: createRelease(),
12 | })
13 |
14 | startTransition(() => {
15 | hydrateRoot(
16 | document,
17 |
18 |
19 | ,
20 | )
21 | })
22 |
--------------------------------------------------------------------------------
/.all-contributorsrc:
--------------------------------------------------------------------------------
1 | {
2 | "projectName": "remix-starter",
3 | "projectOwner": "bradgarropy",
4 | "repoType": "github",
5 | "repoHost": "https://github.com",
6 | "files": ["readme.md", "contributing.md"],
7 | "imageSize": 100,
8 | "contributorsPerLine": 7,
9 | "contributorsSortAlphabetically": false,
10 | "skipCi": false,
11 | "contributors": [
12 | {
13 | "login": "bradgarropy",
14 | "name": "Brad Garropy",
15 | "avatar_url": "https://avatars.githubusercontent.com/u/11336745?v=4",
16 | "profile": "https://bradgarropy.com",
17 | "contributions": ["code", "design", "doc", "infra", "test"]
18 | }
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "baseUrl": ".",
5 | "erasableSyntaxOnly": true,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "isolatedModules": true,
9 | "jsx": "react-jsx",
10 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
11 | "module": "ESNext",
12 | "moduleResolution": "Bundler",
13 | "noEmit": true,
14 | "paths": {
15 | "~/*": ["./src/*"]
16 | },
17 | "resolveJsonModule": true,
18 | "skipLibCheck": true,
19 | "strict": true,
20 | "target": "ESNext",
21 | "types": []
22 | },
23 | "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "env.d.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/Navigation/Navigation.tsx:
--------------------------------------------------------------------------------
1 | import {NavLink} from "@remix-run/react"
2 |
3 | const Navigation = () => {
4 | return (
5 |
26 | )
27 | }
28 |
29 | export default Navigation
30 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import {defineConfig, devices} from "@playwright/test"
2 |
3 | const config = defineConfig({
4 | testDir: "./src/e2e",
5 | fullyParallel: true,
6 | forbidOnly: !!process.env.CI,
7 | retries: process.env.CI ? 2 : 0,
8 | workers: process.env.CI ? 1 : undefined,
9 | reporter: "html",
10 | use: {
11 | trace: "on-first-retry",
12 | },
13 | projects: [
14 | {
15 | name: "chromium",
16 | use: {...devices["Desktop Chrome"]},
17 | },
18 | {
19 | name: "firefox",
20 | use: {...devices["Desktop Firefox"]},
21 | },
22 | {
23 | name: "webkit",
24 | use: {...devices["Desktop Safari"]},
25 | },
26 | ],
27 | webServer: {
28 | command: "npm start",
29 | url: "http://localhost:3000",
30 | reuseExistingServer: !process.env.CI,
31 | },
32 | })
33 |
34 | export default config
35 |
--------------------------------------------------------------------------------
/license:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Brad Garropy
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/e2e/example.spec.ts:
--------------------------------------------------------------------------------
1 | import {expect, test} from "@playwright/test"
2 |
3 | test("home page", async ({page}) => {
4 | await page.goto("localhost:3000")
5 | await expect(page).toHaveTitle("💿 remix starter | home")
6 |
7 | await expect(page.getByRole("heading", {name: "Home"})).toBeVisible()
8 | await expect(page.getByRole("link", {name: "Home"})).toBeVisible()
9 | await expect(page.getByRole("link", {name: "About"})).toBeVisible()
10 | })
11 |
12 | test("about page", async ({page}) => {
13 | await page.goto("localhost:3000/about")
14 | await expect(page).toHaveTitle("💿 remix starter | about")
15 |
16 | await expect(page.getByRole("heading", {name: "About"})).toBeVisible()
17 | await expect(page.getByRole("link", {name: "Home"})).toBeVisible()
18 | await expect(page.getByRole("link", {name: "About"})).toBeVisible()
19 | })
20 |
21 | test("navigates", async ({page}) => {
22 | await page.goto("localhost:3000")
23 |
24 | await expect(page).toHaveTitle("💿 remix starter | home")
25 | await expect(page.getByRole("heading", {name: "Home"})).toBeVisible()
26 |
27 | await page.getByRole("link", {name: "About"}).click()
28 |
29 | await expect(page).toHaveTitle("💿 remix starter | about")
30 | await expect(page.getByRole("heading", {name: "About"})).toBeVisible()
31 | })
32 |
--------------------------------------------------------------------------------
/src/components/ErrorBoundary/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import {isRouteErrorResponse, useRouteError} from "@remix-run/react"
2 |
3 | import {createErrorStack} from "~/utils/errors"
4 |
5 | const ErrorBoundary = () => {
6 | const error = useRouteError()
7 |
8 | if (isRouteErrorResponse(error)) {
9 | return (
10 |
11 |
12 | {`${error.status} ${error.statusText}`}
13 |
14 |
15 |
{error.data}
16 |
17 | )
18 | }
19 |
20 | if (error instanceof Error) {
21 | return (
22 |
23 |
24 | {`Error: ${error.message}`}
25 |
26 |
27 |
{createErrorStack(error)}
28 |
29 | )
30 | }
31 |
32 | return (
33 |
34 |
Unknown error
35 |
36 | )
37 | }
38 |
39 | export default ErrorBoundary
40 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "label": "e2e",
6 | "type": "process",
7 | "command": "npm",
8 | "args": ["run", "test:e2e:ui"],
9 | "icon": {
10 | "id": "dashboard"
11 | },
12 | "isBackground": true,
13 | "runOptions": {
14 | "runOn": "folderOpen"
15 | }
16 | },
17 | {
18 | "label": "unit",
19 | "type": "process",
20 | "command": "npm",
21 | "args": ["run", "test:watch"],
22 | "icon": {
23 | "id": "beaker"
24 | },
25 | "isBackground": true,
26 | "runOptions": {
27 | "runOn": "folderOpen"
28 | }
29 | },
30 | {
31 | "label": "app",
32 | "type": "process",
33 | "command": "npm",
34 | "args": ["run", "dev"],
35 | "icon": {
36 | "id": "preview"
37 | },
38 | "isBackground": true,
39 | "runOptions": {
40 | "runOn": "folderOpen"
41 | }
42 | },
43 | {
44 | "label": "cli",
45 | "type": "process",
46 | "command": "",
47 | "icon": {
48 | "id": "terminal"
49 | },
50 | "isBackground": true,
51 | "runOptions": {
52 | "runOn": "folderOpen"
53 | }
54 | }
55 | ]
56 | }
57 |
--------------------------------------------------------------------------------
/src/components/ErrorBoundary/ErrorBoundary.test.tsx:
--------------------------------------------------------------------------------
1 | import type {ErrorResponse} from "@remix-run/react"
2 | import {isRouteErrorResponse, useRouteError} from "@remix-run/react"
3 | import {render, screen} from "@testing-library/react"
4 | import {expect, test, vitest} from "vitest"
5 |
6 | import ErrorBoundary from "~/components/ErrorBoundary"
7 |
8 | vitest.mock("@remix-run/react", () => ({
9 | useRouteError: vitest.fn(),
10 | isRouteErrorResponse: vitest.fn(),
11 | }))
12 |
13 | const useRouteErrorMock = vitest.mocked(useRouteError)
14 | const isRouteErrorResponseMock = vitest.mocked(isRouteErrorResponse)
15 |
16 | test("shows route error", () => {
17 | const mockErrorResponse: ErrorResponse = {
18 | status: 500,
19 | statusText: "Internal server error",
20 | data: "Something went wrong",
21 | }
22 |
23 | useRouteErrorMock.mockReturnValue(mockErrorResponse)
24 | isRouteErrorResponseMock.mockReturnValue(true)
25 |
26 | render()
27 |
28 | expect(screen.getByText("500 Internal server error")).toBeInTheDocument()
29 | expect(screen.getByText("Something went wrong")).toBeInTheDocument()
30 | })
31 |
32 | test("shows javascript error", () => {
33 | const mockError = new Error("Something went wrong", {cause: "Unknown"})
34 |
35 | useRouteErrorMock.mockReturnValue(mockError)
36 | isRouteErrorResponseMock.mockReturnValue(false)
37 |
38 | render()
39 |
40 | expect(screen.getByText("Error: Something went wrong")).toBeInTheDocument()
41 | expect(screen.getByText("at file://", {exact: false})).toBeInTheDocument()
42 | })
43 |
44 | test("shows unknown error", () => {
45 | useRouteErrorMock.mockReturnValue("Something went wrong")
46 | isRouteErrorResponseMock.mockReturnValue(false)
47 |
48 | render()
49 |
50 | expect(screen.getByText("Unknown error")).toBeInTheDocument()
51 | })
52 |
--------------------------------------------------------------------------------
/src/root.tsx:
--------------------------------------------------------------------------------
1 | import {Links, Meta, Outlet, Scripts, ScrollRestoration} from "@remix-run/react"
2 | import {withSentry} from "@sentry/remix"
3 |
4 | import Error from "~/components/ErrorBoundary"
5 | import Footer from "~/components/Footer"
6 | import Header from "~/components/Header"
7 | import tailwindStyles from "~/styles/tailwind.css?url"
8 |
9 | const App = () => {
10 | return (
11 |
12 |
13 | 💿 remix starter
14 |
15 |
16 |
17 |
21 |
22 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | )
47 | }
48 |
49 | export const ErrorBoundary = () => {
50 | return (
51 |
52 |
53 | 💿 remix starter
54 |
55 |
56 |
57 |
61 |
62 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | )
78 | }
79 |
80 | export default withSentry(App)
81 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # 💿 remix starter
2 |
3 | _A [Remix][remix] starter with [ESLint][eslint], [Prettier][prettier], [TypeScript][typescript], [Vitest][vitest], and [Tailwind][tailwind] included._
4 |
5 | [![vercel][vercel-badge]][vercel]
6 | [![github actions][github-actions-badge]][github-actions]
7 | [![codecov][codecov-badge]][codecov]
8 | [![contributing][contributing-badge]][contributing]
9 | [![contributors][contributors-badge]][contributors]
10 | [![discord][discord-badge]][discord]
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | ## ✨ contributors
19 |
20 |
21 |
22 |
23 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | [vercel]: https://vercel.com/bradgarropy/remix-starter
35 | [vercel-badge]: https://img.shields.io/github/deployments/bradgarropy/remix-starter/production?label=vercel&style=flat-square
36 | [github-actions]: https://github.com/bradgarropy/remix-starter/actions
37 | [github-actions-badge]: https://img.shields.io/github/workflow/status/bradgarropy/remix-starter/%F0%9F%A7%AA%20test?style=flat-square
38 | [codecov]: https://app.codecov.io/gh/bradgarropy/remix-starter
39 | [codecov-badge]: https://img.shields.io/codecov/c/github/bradgarropy/remix-starter?style=flat-square
40 | [contributing]: https://github.com/bradgarropy/remix-starter/blob/main/contributing.md
41 | [contributing-badge]: https://img.shields.io/badge/PRs-welcome-success?style=flat-square
42 | [contributors]: #-Contributors
43 | [contributors-badge]: https://img.shields.io/github/all-contributors/bradgarropy/remix-starter?style=flat-square
44 | [discord]: https://bradgarropy.com/discord
45 | [discord-badge]: https://img.shields.io/discord/748196643140010015?style=flat-square
46 | [eslint]: https://eslint.org
47 | [prettier]: https://prettier.io
48 | [typescript]: https://typescriptlang.org
49 | [vitest]: https://vitest.dev
50 | [tailwind]: https://tailwindcss.com
51 | [remix]: https://remix.run
52 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import {vitePlugin as remix} from "@remix-run/dev"
2 | import {installGlobals} from "@remix-run/node"
3 | import {sentryVitePlugin as sentry} from "@sentry/vite-plugin"
4 | import tailwind from "@tailwindcss/vite"
5 | import react from "@vitejs/plugin-react"
6 | import {remixDevTools} from "remix-development-tools"
7 | import tsconfigPaths from "vite-tsconfig-paths"
8 | import {defineConfig} from "vitest/config"
9 |
10 | import {createRelease} from "./src/utils/sentry"
11 |
12 | installGlobals()
13 |
14 | declare module "@remix-run/node" {
15 | interface Future {
16 | v3_singleFetch: true
17 | }
18 | }
19 |
20 | const config = defineConfig({
21 | build: {
22 | sourcemap: true,
23 | },
24 | plugins: [
25 | tsconfigPaths(),
26 | tailwind(),
27 | remixDevTools({
28 | client: {
29 | showBreakpointIndicator: false,
30 | },
31 | }),
32 | process.env.VITEST
33 | ? react()
34 | : remix({
35 | appDirectory: "src",
36 | ignoredRouteFiles: ["**/.*"],
37 | future: {
38 | v3_fetcherPersist: true,
39 | v3_relativeSplatPath: true,
40 | v3_throwAbortReason: true,
41 | v3_lazyRouteDiscovery: true,
42 | v3_singleFetch: true,
43 | v3_routeConfig: true,
44 | },
45 | serverModuleFormat: "esm",
46 | }),
47 | process.env.SENTRY_AUTH_TOKEN
48 | ? sentry({
49 | authToken: process.env.SENTRY_AUTH_TOKEN,
50 | org: process.env.SENTRY_ORG,
51 | project: process.env.SENTRY_PROJECT,
52 | release: {
53 | create: true,
54 | name: createRelease(),
55 | },
56 | sourcemaps: {
57 | filesToDeleteAfterUpload: [
58 | "build/client/**/*.map",
59 | "build/server/**/*.map",
60 | ],
61 | },
62 | telemetry: false,
63 | })
64 | : null,
65 | ],
66 | server: {
67 | open: true,
68 | port: 3000,
69 | },
70 | test: {
71 | clearMocks: true,
72 | coverage: {
73 | all: false,
74 | clean: true,
75 | enabled: true,
76 | provider: "v8",
77 | reporter: ["text", "lcov", "html"],
78 | reportOnFailure: false,
79 | },
80 | environment: "jsdom",
81 | include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
82 | globals: false,
83 | passWithNoTests: true,
84 | setupFiles: "src/tests/setup.ts",
85 | watch: false,
86 | },
87 | })
88 |
89 | export default config
90 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: "🏭 ci"
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | lint:
11 | name: "🧶 lint"
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: "📚 checkout"
15 | uses: actions/checkout@v4
16 | - name: "🟢 node"
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: 22
20 | cache: "npm"
21 | - name: "📦 install"
22 | run: npm ci
23 | - name: "🧶 lint"
24 | run: npm run lint
25 | format:
26 | name: "💾 format"
27 | runs-on: ubuntu-latest
28 | steps:
29 | - name: "📚 checkout"
30 | uses: actions/checkout@v4
31 | - name: "🟢 node"
32 | uses: actions/setup-node@v4
33 | with:
34 | node-version: 22
35 | cache: "npm"
36 | - name: "📦 install"
37 | run: npm ci
38 | - name: "💾 format"
39 | run: npm run format
40 | typecheck:
41 | name: "🟦 typecheck"
42 | runs-on: ubuntu-latest
43 | steps:
44 | - name: "📚 checkout"
45 | uses: actions/checkout@v4
46 | - name: "🟢 node"
47 | uses: actions/setup-node@v4
48 | with:
49 | node-version: 22
50 | cache: "npm"
51 | - name: "📦 install"
52 | run: npm ci
53 | - name: "🟦 typecheck"
54 | run: npm run typecheck
55 | test:
56 | name: "🧪 test"
57 | runs-on: ubuntu-latest
58 | steps:
59 | - name: "📚 checkout"
60 | uses: actions/checkout@v4
61 | - name: "🟢 node"
62 | uses: actions/setup-node@v4
63 | with:
64 | node-version: 22
65 | cache: "npm"
66 | - name: "📦 install"
67 | run: npm ci
68 | - name: "🧪 test"
69 | run: npm run test
70 | - name: "☂️ coverage"
71 | uses: codecov/codecov-action@v5
72 | with:
73 | token: ${{ secrets.CODECOV_TOKEN }}
74 | e2e:
75 | name: "🎭 e2e"
76 | runs-on: ubuntu-latest
77 | steps:
78 | - name: "📚 checkout"
79 | uses: actions/checkout@v4
80 | - name: "🟢 node"
81 | uses: actions/setup-node@v4
82 | with:
83 | node-version: 22
84 | cache: "npm"
85 | - name: "📦 install"
86 | run: npm ci
87 | - name: "🌐 browsers"
88 | run: npx playwright install --with-deps
89 | - name: "🛠️ build"
90 | run: npm run build
91 | - name: "🎭 e2e"
92 | run: npm run test:e2e
93 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix-starter",
3 | "version": "4.10.2",
4 | "description": "💿 remix starter",
5 | "type": "module",
6 | "keywords": [
7 | "typescript",
8 | "react",
9 | "eslint",
10 | "prettier",
11 | "vitest",
12 | "vercel",
13 | "remix",
14 | "remix-stack",
15 | "tailwind",
16 | "hacktoberfest"
17 | ],
18 | "homepage": "https://github.com/bradgarropy/remix-starter",
19 | "bugs": {
20 | "url": "https://github.com/bradgarropy/remix-starter/issues"
21 | },
22 | "license": "MIT",
23 | "author": {
24 | "name": "Brad Garropy",
25 | "email": "bradgarropy@gmail.com",
26 | "url": "https://bradgarropy.com"
27 | },
28 | "repository": {
29 | "type": "git",
30 | "url": "https://github.com/bradgarropy/remix-starter"
31 | },
32 | "scripts": {
33 | "dev": "remix vite:dev",
34 | "build": "remix vite:build",
35 | "start": "remix-serve build/server/index.js",
36 | "lint": "eslint .",
37 | "lint:fix": "eslint . --fix",
38 | "format": "prettier --check .",
39 | "format:fix": "prettier --write .",
40 | "typecheck": "tsc",
41 | "test": "vitest --coverage",
42 | "test:ui": "vitest --coverage --watch --ui",
43 | "test:watch": "vitest --coverage --watch",
44 | "test:e2e": "playwright test",
45 | "test:e2e:ui": "playwright test --ui",
46 | "contributors": "all-contributors generate",
47 | "new": "plop"
48 | },
49 | "dependencies": {
50 | "@remix-run/node": "^2.2.0",
51 | "@remix-run/react": "^2.2.0",
52 | "@remix-run/vercel": "^1.19.3",
53 | "@sentry/remix": "^8.33.1",
54 | "isbot": "^5.1.1",
55 | "react": "^19.0.0",
56 | "react-dom": "^19.0.0"
57 | },
58 | "devDependencies": {
59 | "@bradgarropy/eslint-config": "^3.0.0",
60 | "@bradgarropy/plop-generator-remix-route": "^1.0.0",
61 | "@playwright/test": "^1.48.0",
62 | "@remix-run/dev": "^2.2.0",
63 | "@remix-run/route-config": "^2.15.1",
64 | "@remix-run/serve": "^2.2.0",
65 | "@sentry/vite-plugin": "^2.22.5",
66 | "@tailwindcss/vite": "^4.0.0",
67 | "@testing-library/dom": "^10.4.0",
68 | "@testing-library/jest-dom": "^6.1.4",
69 | "@testing-library/react": "^16.0.0",
70 | "@testing-library/user-event": "^14.4.3",
71 | "@types/node": "^22.7.5",
72 | "@types/react": "^19.0.1",
73 | "@types/react-dom": "^19.0.2",
74 | "@vitejs/plugin-react": "^4.0.1",
75 | "@vitest/coverage-v8": "^3.0.4",
76 | "@vitest/ui": "^3.0.4",
77 | "all-contributors-cli": "^6.20.0",
78 | "autoprefixer": "^10.4.8",
79 | "jsdom": "^26.0.0",
80 | "plop": "^4.0.0",
81 | "prettier": "^3.1.0",
82 | "prettier-plugin-tailwindcss": "^0.6.11",
83 | "remix-development-tools": "^4.7.7",
84 | "tailwindcss": "^4.0.0",
85 | "typescript": "^5.0.4",
86 | "vite": "^6.0.3",
87 | "vite-tsconfig-paths": "^5.0.1",
88 | "vitest": "^3.0.4"
89 | },
90 | "engines": {
91 | "node": ">=14"
92 | },
93 | "sideEffects": false
94 | }
95 |
--------------------------------------------------------------------------------
/contributing.md:
--------------------------------------------------------------------------------
1 | # 👨🏼💻 contributing
2 |
3 | I would love your help to improve this project! Here are a few ways to contribute, and some guidelines to help you along the way.
4 |
5 | ## 🐛 issues
6 |
7 | If you come across any bugs or something that doesn't seem right, please [open an issue][issues]. Also, if you have an idea for the project, open an issue to start the discussion.
8 |
9 | When possible, please include a link to a `git` repository or a [CodeSandbox][codesandbox] which illustrates the problem you're facing. This is especially important when you find a bug.
10 |
11 | ## 🔃 pull requests
12 |
13 | Yes, I accept pull requests! You can submit a pull request to fix a bug, implement a feature, add tests, or improve the documentation.
14 |
15 | If you've never created a pull request before, you can [learn how][kcd-pr] for free!
16 |
17 | ### 🎛 setup
18 |
19 | In order to submit a pull request, you'll have to setup your own development environment. Start by [forking][fork] the repository.
20 |
21 | Then you can clone the forked repository to your system.
22 |
23 | ```bash
24 | git clone https://github.com//remix-starter
25 | ```
26 |
27 | Next you need to install the dependencies.
28 |
29 | ```bash
30 | cd remix-starter
31 | npm install
32 | ```
33 |
34 | Finally, you can build and test the project.
35 |
36 | ```bash
37 | npm run test
38 | npm run build
39 | ```
40 |
41 | Now you're ready to start writing code!
42 |
43 | ### 💎 format
44 |
45 | When writing your code, please try to follow the existing code style.
46 |
47 | Your code will be automatically linted and formatted before each commit. However, if you want to manually lint and format, use the provided `npm` scripts.
48 |
49 | ```bash
50 | npm run lint:fix
51 | npm run format:fix
52 | ```
53 |
54 | ### 🧪 tests
55 |
56 | The project maintains `100%` test coverage. If you change code, please maintain complete test coverage. You can run the tests to confirm.
57 |
58 | ```bash
59 | npm run test
60 | ```
61 |
62 | ### 📖 documentation
63 |
64 | If you make any changes that require documentation updates, please include them in the same pull request.
65 |
66 | ### 🔹 commits
67 |
68 | This project do not enforce a specific commit style. However, if you submit a pull request that closes an issue, please reference it in the commit message.
69 |
70 | ```bash
71 | git commit -m "Fix a bug. Closes #1."
72 | ```
73 |
74 | ### 💬 feedback
75 |
76 | Once your pull request is submitted, I may provide you with some feedback. While working on the feedback, please move the pull request to `Draft` state. Once you've finished addressing the feedback, mark the pull request as `Ready for review` and mention me in a comment.
77 |
78 | ```
79 | Alright @bradgarropy, how's this?
80 | ```
81 |
82 | ### ⚖ license
83 |
84 | Any code you contribute is subject to the [MIT license][license].
85 |
86 | ## ✨ contributors
87 |
88 | I appreciate any and all types of contributions to this project! Contributors are recognized here and in the [`readme`][contributors].
89 |
90 |
91 |
92 |
93 |
98 |
99 |
100 |
101 |
102 |
103 |
104 | [issues]: https://github.com/bradgarropy/remix-starter/issues
105 | [codesandbox]: https://codesandbox.io
106 | [kcd-pr]: https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github
107 | [license]: https://github.com/bradgarropy/remix-starter/blob/main/license
108 | [fork]: https://github.com/bradgarropy/remix-starter/fork
109 | [contributors]: https://github.com/bradgarropy/remix-starter#-contributors
110 |
--------------------------------------------------------------------------------
/src/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import {PassThrough} from "node:stream"
2 |
3 | import type {EntryContext} from "@remix-run/node"
4 | import {createReadableStreamFromReadable} from "@remix-run/node"
5 | import {RemixServer} from "@remix-run/react"
6 | import * as Sentry from "@sentry/remix"
7 | import {isbot} from "isbot"
8 | import {renderToPipeableStream} from "react-dom/server"
9 |
10 | import {createRelease} from "~/utils/sentry"
11 |
12 | const streamTimeout = 5000
13 |
14 | Sentry.init({
15 | dsn: process.env.VITE_SENTRY_DSN,
16 | environment: process.env.NODE_ENV,
17 | release: createRelease(),
18 | })
19 |
20 | export const handleError = Sentry.sentryHandleError
21 |
22 | const handleRequest = (
23 | request: Request,
24 | responseStatusCode: number,
25 | responseHeaders: Headers,
26 | remixContext: EntryContext,
27 | ) => {
28 | return isbot(request.headers.get("user-agent") ?? "")
29 | ? handleBotRequest(
30 | request,
31 | responseStatusCode,
32 | responseHeaders,
33 | remixContext,
34 | )
35 | : handleBrowserRequest(
36 | request,
37 | responseStatusCode,
38 | responseHeaders,
39 | remixContext,
40 | )
41 | }
42 |
43 | const handleBotRequest = (
44 | request: Request,
45 | responseStatusCode: number,
46 | responseHeaders: Headers,
47 | remixContext: EntryContext,
48 | ) => {
49 | return new Promise((resolve, reject) => {
50 | let shellRendered = false
51 | const {pipe, abort} = renderToPipeableStream(
52 | ,
57 | {
58 | onAllReady() {
59 | shellRendered = true
60 | const body = new PassThrough()
61 | const stream = createReadableStreamFromReadable(body)
62 |
63 | responseHeaders.set("Content-Type", "text/html")
64 |
65 | resolve(
66 | new Response(stream, {
67 | headers: responseHeaders,
68 | status: responseStatusCode,
69 | }),
70 | )
71 |
72 | pipe(body)
73 | },
74 | onShellError(error: unknown) {
75 | reject(error)
76 | },
77 | onError(error: unknown) {
78 | responseStatusCode = 500
79 | // Log streaming rendering errors from inside the shell. Don't log
80 | // errors encountered during initial shell rendering since they'll
81 | // reject and get logged in handleDocumentRequest.
82 | if (shellRendered) {
83 | console.error(error)
84 | }
85 | },
86 | },
87 | )
88 |
89 | setTimeout(abort, streamTimeout)
90 | })
91 | }
92 |
93 | const handleBrowserRequest = (
94 | request: Request,
95 | responseStatusCode: number,
96 | responseHeaders: Headers,
97 | remixContext: EntryContext,
98 | ) => {
99 | return new Promise((resolve, reject) => {
100 | let shellRendered = false
101 | const {pipe, abort} = renderToPipeableStream(
102 | ,
107 | {
108 | onShellReady() {
109 | shellRendered = true
110 | const body = new PassThrough()
111 | const stream = createReadableStreamFromReadable(body)
112 |
113 | responseHeaders.set("Content-Type", "text/html")
114 |
115 | resolve(
116 | new Response(stream, {
117 | headers: responseHeaders,
118 | status: responseStatusCode,
119 | }),
120 | )
121 |
122 | pipe(body)
123 | },
124 | onShellError(error: unknown) {
125 | reject(error)
126 | },
127 | onError(error: unknown) {
128 | responseStatusCode = 500
129 | // Log streaming rendering errors from inside the shell. Don't log
130 | // errors encountered during initial shell rendering since they'll
131 | // reject and get logged in handleDocumentRequest.
132 | if (shellRendered) {
133 | console.error(error)
134 | }
135 | },
136 | },
137 | )
138 |
139 | setTimeout(abort, streamTimeout + 1000)
140 | })
141 | }
142 |
143 | export default handleRequest
144 | export {streamTimeout}
145 |
--------------------------------------------------------------------------------
/changelog.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog][keep-a-changelog],
6 | and this project adheres to [Semantic Versioning][semver].
7 |
8 |
34 |
35 | ## [Unreleased]
36 |
37 | - _TBD_
38 |
39 | ## [4.10.2][4.10.2]
40 |
41 | _2025-04-25_
42 |
43 | - Support TypeScript's [`erasableSyntaxOnly`][erasable-syntax-only] configuration
44 |
45 | ## [4.10.1][4.10.1]
46 |
47 | _2025-04-24_
48 |
49 | - Link Tailwind styles in `root`
50 |
51 | ## [4.10.0][4.10.0]
52 |
53 | _2025-04-23_
54 |
55 | - Use React [metadata tags][metadata]
56 |
57 | ## [4.9.0][4.9.0]
58 |
59 | _2025-02-27_
60 |
61 | - Automate terminals with [vscode tasks][vscode-tasks]
62 |
63 | ## [4.8.0][4.8.0]
64 |
65 | _2025-02-26_
66 |
67 | - Combine [GitHub Actions][github-actions] into a single `ci` workflow
68 |
69 | ## [4.7.0][4.7.0]
70 |
71 | _2025-01-30_
72 |
73 | - Add root [`ErrorBoundary`][error-boundary]
74 |
75 | ## [4.6.0][4.6.0]
76 |
77 | _2025-01-29_
78 |
79 | - Upgrade to [Tailwind v4][tailwind-v4]
80 |
81 | ## [4.5.0][4.5.0]
82 |
83 | _2025-01-23_
84 |
85 | - Upgrade to [Vitest 3][vitest-3]
86 |
87 | ## [4.4.0][4.4.0]
88 |
89 | _2025-01-12_
90 |
91 | - Hide breakpoint indicator in [`remix-development-tools`][remix-development-tools]
92 |
93 | ## [4.3.0][4.3.0]
94 |
95 | _2025-01-09_
96 |
97 | - Integrate [`remix-development-tools`][remix-development-tools]
98 | - Update [codecov action][codecov-action]
99 | - Add [`vercel`][vercel-config] configuration
100 |
101 | ## [4.2.0][4.2.0]
102 |
103 | _2024-12-17_
104 |
105 | - Improve `eslint` configuration
106 |
107 | ## [4.1.0][4.1.0]
108 |
109 | _2024-12-10_
110 |
111 | - Fix the [`/api/hello`][api-hello] route
112 | - Adopt the [`v3_routeConfig`][v3-routeConfig] future flag
113 |
114 | ## [4.0.0][4.0.0]
115 |
116 | _2024-12-10_
117 |
118 | - Upgrade to [React 19][react-19]
119 | - Upgrade to [Vite 6][vite-6]
120 | - Adopt Remix future flags
121 | - [`v3_fetcherPersist`][v3-fetcherPersist]
122 | - [`v3_relativeSplatPath`][v3-relativeSplatPath]
123 | - [`v3_throwAbortReason`][v3-throwAbortReason]
124 | - [`v3_lazyRouteDiscovery`][v3-lazyRouteDiscovery]
125 | - [`v3_singleFetch`][v3-singleFetch]
126 | - Remove [`@remix-run/eslint-config`][remix-run-eslint-config]
127 |
128 | [unreleased]: https://github.com/bradgarropy/remix-starter/compare/v4.10.2...HEAD
129 | [4.10.2]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.10.2
130 | [4.10.1]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.10.1
131 | [4.10.0]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.10.0
132 | [4.9.0]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.9.0
133 | [4.8.0]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.8.0
134 | [4.7.0]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.7.0
135 | [4.6.0]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.6.0
136 | [4.5.0]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.5.0
137 | [4.4.0]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.4.0
138 | [4.3.0]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.3.0
139 | [4.2.0]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.2.0
140 | [4.1.0]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.1.0
141 | [4.0.0]: https://github.com/bradgarropy/remix-starter/releases/tag/v4.0.0
142 | [keep-a-changelog]: https://keepachangelog.com
143 | [semver]: https://semver.org
144 | [react-19]: https://react.dev/blog/2024/12/05/react-19
145 | [vite-6]: https://vite.dev/blog/announcing-vite6
146 | [v3-fetcherPersist]: https://remix.run/docs/en/main/start/future-flags#v3_fetcherpersist
147 | [v3-relativeSplatPath]: https://remix.run/docs/en/main/start/future-flags#v3_relativesplatpath
148 | [v3-throwAbortReason]: https://remix.run/docs/en/main/start/future-flags#v3_throwabortreason
149 | [v3-lazyRouteDiscovery]: https://remix.run/docs/en/main/start/future-flags#v3_lazyroutediscovery
150 | [v3-singleFetch]: https://remix.run/docs/en/main/start/future-flags#v3_singlefetch
151 | [v3-routeConfig]: https://remix.run/docs/en/main/start/future-flags#v3_routeconfig
152 | [remix-run-eslint-config]: https://remix.run/docs/en/main/start/future-flags#remix-runeslint-config
153 | [api-hello]: https://remix-starter-bradgarropy.vercel.app/api/hello
154 | [remix-development-tools]: https://remix-development-tools.fly.dev
155 | [codecov-action]: https://github.com/codecov/codecov-action
156 | [vercel-config]: https://vercel.com/docs/projects/project-configuration
157 | [vitest-3]: https://vitest.dev/guide/migration.html#vitest-3
158 | [tailwind-v4]: https://tailwindcss.com/docs/upgrade-guide
159 | [error-boundary]: https://remix.run/docs/en/main/route/error-boundary
160 | [github-actions]: https://github.com/features/actions
161 | [vscode-tasks]: https://code.visualstudio.com/docs/terminal/basics#_automating-terminals-with-tasks
162 | [metadata]: https://react.dev/blog/2024/12/05/react-19#support-for-metadata-tags
163 | [erasable-syntax-only]: https://typescriptlang.org/tsconfig/#erasableSyntaxOnly
164 |
--------------------------------------------------------------------------------