52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/docs/decisions/015-monitoring.md:
--------------------------------------------------------------------------------
1 | # Monitoring
2 |
3 | Date: 2023-06-09
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | Unless you want to be watching your metrics and logs 24/7 you probably want to
10 | be notified when users experience errors in your application. There are great
11 | tools for monitoring your application. I've used Sentry for years and it's
12 | great.
13 |
14 | One of the guiding principles of the project is to avoid services. The nature of
15 | application monitoring requires that the monitor not be part of the application.
16 | So, we necessarily need to use a service for monitoring.
17 |
18 | One nice thing about Sentry is it is open source so we can run it ourselves if
19 | we like. However, that may be more work than we want to take on at first.
20 |
21 | ## Decision
22 |
23 | We'll set up the Epic Stack to use Sentry and document how you could get it
24 | running yourself if you prefer to self-host it.
25 |
26 | We'll also ensure that we defer the setup requirement to later so you can still
27 | get started with the Epic Stack without monitoring in place which is very useful
28 | for experiments and makes it easier to remove or adapt to a different solution
29 | if you so desire.
30 |
31 | ## Consequences
32 |
33 | We tie the Epic Stack to Sentry a bit, but I think that's a solid trade-off for
34 | the benefit of production error monitoring that Sentry provides. People who need
35 | the scale where Sentry starts to cost money (https://sentry.io/pricing/) will
36 | probably be making money at that point and will be grateful for the monitoring.
37 |
--------------------------------------------------------------------------------
/other/svg-icons/avatar.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/app/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
2 | import * as React from 'react'
3 |
4 | import { cn } from '#app/utils/misc.tsx'
5 |
6 | function TooltipProvider(
7 | props: React.ComponentProps,
8 | ) {
9 | return
10 | }
11 |
12 | function Tooltip(props: React.ComponentProps) {
13 | return (
14 |
15 |
16 |
17 | )
18 | }
19 |
20 | function TooltipTrigger(
21 | props: React.ComponentProps,
22 | ) {
23 | return
24 | }
25 |
26 | const TooltipContent = ({
27 | className,
28 | sideOffset = 4,
29 | ...props
30 | }: React.ComponentProps) => (
31 |
40 | )
41 |
42 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
43 |
--------------------------------------------------------------------------------
/docs/decisions/026-path-aliases.md:
--------------------------------------------------------------------------------
1 | # Path Aliases
2 |
3 | Date: 2023-08-14
4 |
5 | Status: superseded by [031-imports](./031-imports.md)
6 |
7 | ## Context
8 |
9 | It's pretty common to configure TypeScript to have path aliases for imports.
10 | This allows you to avoid relative imports and makes it easier to move files
11 | around without having to update imports.
12 |
13 | When the Epic Stack started, we used path imports that were similar to those in
14 | the rest of the Remix ecosystem: `#` referenced the `app/` directory. We added
15 | `tests/` to make it easier to import test utils.
16 |
17 | However, we've found that this is confusing for new developers. It's not clear
18 | what `#` means, and seeing `import { thing } from 'tests/thing'` is confusing. I
19 | floated the idea of adding another alias for `@/` to be the app directory and or
20 | possibly just moving the `#` to the root and having that be the only alias. But
21 | at the end of the day, we're using TypeScript which will prevent us from making
22 | mistakes and modern editors will automatically handle imports for you anyway.
23 |
24 | At first it may feel like a pain, but less tooling magic is better and editors
25 | can really help reduce the pain. Additionally, we have ESLint configured to sort
26 | imports for us so we don't have to worry about that either. Just let the editor
27 | update the imports and let ESLint sort them.
28 |
29 | ## Decision
30 |
31 | Remove the path aliases from the `tsconfig`.
32 |
33 | ## Consequences
34 |
35 | This requires updating all the imports that utilized the path aliases to use
36 | relative imports.
37 |
--------------------------------------------------------------------------------
/docs/decisions/033-honeypot.md:
--------------------------------------------------------------------------------
1 | # Honeypot Fields
2 |
3 | Date: 2023-10-11
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | You can learn all about Honeypot Fields from
10 | [EpicWeb.dev's forms workshop](https://forms.epicweb.dev/06). The TL;DR idea is
11 | spam bots go around the internet filling in forms all over the place in hopes of
12 | getting their spammy links on your site among other things. This causes extra
13 | load on your server and in some cases can cause you issues. For example, our
14 | onboarding process sends an email to the user. If a spam bot fills out the form
15 | with a random email address, we'll send an email to that address and cause
16 | confusion in the best case or get marked as spam in the worst case.
17 |
18 | Most of these spam bots are not very sophisticated and will fill in every field
19 | on the form (even if those fields are visually hidden). We can use this to our
20 | advantage by adding a field to the form that is visually hidden and then
21 | checking that it is empty when the form is submitted. If it is not empty, we
22 | know that the form was filled out by a spam bot and we can ignore it.
23 |
24 | There are great tools to help us accomplish this (`remix-utils` specifically).
25 |
26 | ## Decision
27 |
28 | We'll implement Honeypot Fields to all our public-facing forms. Authenticated
29 | forms won't need this because they're not accessible to spam bots anyway.
30 |
31 | ## Consequences
32 |
33 | This is a tiny bit invasive to the code, but it doesn't add much complexity.
34 | It's certainly worth the added benefits to our server (and email
35 | deliverability).
36 |
--------------------------------------------------------------------------------
/app/utils/permissions.server.ts:
--------------------------------------------------------------------------------
1 | import { data } from 'react-router'
2 | import { requireUserId } from './auth.server.ts'
3 | import { prisma } from './db.server.ts'
4 | import { type PermissionString, parsePermissionString } from './user.ts'
5 |
6 | export async function requireUserWithPermission(
7 | request: Request,
8 | permission: PermissionString,
9 | ) {
10 | const userId = await requireUserId(request)
11 | const permissionData = parsePermissionString(permission)
12 | const user = await prisma.user.findFirst({
13 | select: { id: true },
14 | where: {
15 | id: userId,
16 | roles: {
17 | some: {
18 | permissions: {
19 | some: {
20 | ...permissionData,
21 | access: permissionData.access
22 | ? { in: permissionData.access }
23 | : undefined,
24 | },
25 | },
26 | },
27 | },
28 | },
29 | })
30 | if (!user) {
31 | throw data(
32 | {
33 | error: 'Unauthorized',
34 | requiredPermission: permissionData,
35 | message: `Unauthorized: required permissions: ${permission}`,
36 | },
37 | { status: 403 },
38 | )
39 | }
40 | return user.id
41 | }
42 |
43 | export async function requireUserWithRole(request: Request, name: string) {
44 | const userId = await requireUserId(request)
45 | const user = await prisma.user.findFirst({
46 | select: { id: true },
47 | where: { id: userId, roles: { some: { name } } },
48 | })
49 | if (!user) {
50 | throw data(
51 | {
52 | error: 'Unauthorized',
53 | requiredRole: name,
54 | message: `Unauthorized: required role: ${name}`,
55 | },
56 | { status: 403 },
57 | )
58 | }
59 | return user.id
60 | }
61 |
--------------------------------------------------------------------------------
/other/litefs.yml:
--------------------------------------------------------------------------------
1 | # Documented example: https://github.com/superfly/litefs/blob/dec5a7353292068b830001bd2df4830e646f6a2f/cmd/litefs/etc/litefs.yml
2 | fuse:
3 | # Required. This is the mount directory that applications will
4 | # use to access their SQLite databases.
5 | dir: '${LITEFS_DIR}'
6 |
7 | data:
8 | # Path to internal data storage.
9 | dir: '/data/litefs'
10 |
11 | proxy:
12 | # matches the internal_port in fly.toml
13 | addr: ':${INTERNAL_PORT}'
14 | target: 'localhost:${PORT}'
15 | db: '${DATABASE_FILENAME}'
16 |
17 | # The lease section specifies how the cluster will be managed. We're using the
18 | # "consul" lease type so that our application can dynamically change the primary.
19 | #
20 | # These environment variables will be available in your Fly.io application.
21 | lease:
22 | type: 'consul'
23 | candidate: ${FLY_REGION == PRIMARY_REGION}
24 | promote: true
25 | advertise-url: 'http://${HOSTNAME}.vm.${FLY_APP_NAME}.internal:20202'
26 |
27 | consul:
28 | url: '${FLY_CONSUL_URL}'
29 | key: 'epic-stack-litefs_20250222/${FLY_APP_NAME}'
30 |
31 | exec:
32 | - cmd: npx prisma migrate deploy
33 | if-candidate: true
34 |
35 | # Set the journal mode for the database to WAL. This reduces concurrency deadlock issues
36 | - cmd: sqlite3 $DATABASE_PATH "PRAGMA journal_mode = WAL;"
37 | if-candidate: true
38 |
39 | # Set the journal mode for the cache to WAL. This reduces concurrency deadlock issues
40 | - cmd: sqlite3 $CACHE_DATABASE_PATH "PRAGMA journal_mode = WAL;"
41 | if-candidate: true
42 |
43 | # Generate Typed SQL files
44 | - cmd: npx prisma generate --sql
45 |
46 | - cmd: npm start
47 |
--------------------------------------------------------------------------------
/app/routes/$.tsx:
--------------------------------------------------------------------------------
1 | // This is called a "splat route" and as it's in the root `/app/routes/`
2 | // directory, it's a catchall. If no other routes match, this one will and we
3 | // can know that the user is hitting a URL that doesn't exist. By throwing a
4 | // 404 from the loader, we can force the error boundary to render which will
5 | // ensure the user gets the right status code and we can display a nicer error
6 | // message for them than the Remix and/or browser default.
7 |
8 | import { Link, useLocation } from 'react-router'
9 | import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
10 | import { Icon } from '#app/components/ui/icon.tsx'
11 |
12 | export function loader() {
13 | throw new Response('Not found', { status: 404 })
14 | }
15 |
16 | export function action() {
17 | throw new Response('Not found', { status: 404 })
18 | }
19 |
20 | export default function NotFound() {
21 | // due to the loader, this component will never be rendered, but we'll return
22 | // the error boundary just in case.
23 | return
24 | }
25 |
26 | export function ErrorBoundary() {
27 | const location = useLocation()
28 | return (
29 | (
32 |
33 |
34 |
We can't find this page:
35 |
36 | {location.pathname}
37 |
38 |
39 |
40 | Back to home
41 |
42 |
43 | ),
44 | }}
45 | />
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/other/svg-icons/github-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
--------------------------------------------------------------------------------
/docs/decisions/006-native-esm.md:
--------------------------------------------------------------------------------
1 | # Native ESM
2 |
3 | Date: 2023-05-18
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | Oh boy, where do I start? The history of JavaScript modules is long and
10 | complicated. I discuss this a bit in my talk
11 | [More than you want to know about ES6 Modules](https://kentcdodds.com/talks/more-than-you-want-to-know-about-es-6-modules).
12 | Many modern packages on npm are now publishing esm-only versions of their
13 | packages. This is fine, but it does mean that using them from a CommonJS module
14 | system requires dynamic imports which is limiting.
15 |
16 | In Remix v2, ESM will be the default behavior. Everywhere you look, ESM is
17 | becoming more and more the standard module option. CommonJS modules aren't going
18 | anywhere, but it's a good idea to stay on top of the latest.
19 |
20 | Sadly, this is a bit of a "who moved my cheese" situation. Developers who are
21 | familiar with CommonJS modules will be annoyed by things they were used to doing
22 | in CJS that they can't do the same way in ESM. The biggest is dynamic (and
23 | synchronous) requires. Another is the way that module resolution changes. There
24 | are some packages which aren't quite prepared for ESM and therefore you end up
25 | having to import their exports directly from the files (like radix for example).
26 | This is hopefully a temporary problem.
27 |
28 | ## Decision
29 |
30 | We're adopting ESM as the default module system for the Epic Stack.
31 |
32 | ## Consequences
33 |
34 | Experienced developers will hit a couple bumps along the way as they change
35 | their mental model for modules. But it's time to do this.
36 |
37 | Some tools aren't very ergonomic with ESM. This will hopefully improve over
38 | time.
39 |
--------------------------------------------------------------------------------
/docs/permissions.md:
--------------------------------------------------------------------------------
1 | # Permissions
2 |
3 | The Epic Stack's Permissions model takes after
4 | [Role-Based Access Control (RBAC)](https://auth0.com/intro-to-iam/what-is-role-based-access-control-rbac).
5 | Each user has a set of roles, and each role has a set of permissions. A user's
6 | permissions are the union of the permissions of all their roles (with the more
7 | permissive permission taking precedence).
8 |
9 | The default development seed creates fine-grained permissions that include
10 | `create`, `read`, `update`, and `delete` permissions for `user` and `note` with
11 | the access of `own` and `any`. The default seed also creates `user` and `admin`
12 | roles with the sensible permissions for those roles.
13 |
14 | You can combine these permissions in different ways to support different roles
15 | for different personas of users of your application.
16 |
17 | The Epic Stack comes with built-in utilities for working with these permissions.
18 | Here are some examples to give you an idea:
19 |
20 | ```ts
21 | // server-side only utilities
22 | const userCanDeleteAnyUser = await requireUserWithPermission(
23 | request,
24 | 'delete:user:any',
25 | )
26 | const userIsAdmin = await requireUserWithRole(request, 'admin')
27 | ```
28 |
29 | ```ts
30 | // UI utilities
31 | const user = useUser()
32 | const userCanCreateTheirOwnNotes = userHasPermission(user, 'create:note:own')
33 | const userIsUser = userHasRole(user, 'user')
34 | ```
35 |
36 | There is currently no UI for managing permissions, but you can use prisma studio
37 | for establishing these.
38 |
39 | ## Seeding the production database
40 |
41 | Check [the deployment docs](./deployment.md) for instructions on how to seed the
42 | production database with the roles you want.
43 |
--------------------------------------------------------------------------------
/app/utils/client-hints.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * This file contains utilities for using client hints for user preference which
3 | * are needed by the server, but are only known by the browser.
4 | */
5 | import { getHintUtils } from '@epic-web/client-hints'
6 | import {
7 | clientHint as colorSchemeHint,
8 | subscribeToSchemeChange,
9 | } from '@epic-web/client-hints/color-scheme'
10 | import { clientHint as timeZoneHint } from '@epic-web/client-hints/time-zone'
11 | import * as React from 'react'
12 | import { useRevalidator } from 'react-router'
13 | import { useOptionalRequestInfo, useRequestInfo } from './request-info.ts'
14 |
15 | const hintsUtils = getHintUtils({
16 | theme: colorSchemeHint,
17 | timeZone: timeZoneHint,
18 | // add other hints here
19 | })
20 |
21 | export const { getHints } = hintsUtils
22 |
23 | /**
24 | * @returns an object with the client hints and their values
25 | */
26 | export function useHints() {
27 | const requestInfo = useRequestInfo()
28 | return requestInfo.hints
29 | }
30 |
31 | export function useOptionalHints() {
32 | const requestInfo = useOptionalRequestInfo()
33 | return requestInfo?.hints
34 | }
35 |
36 | /**
37 | * @returns inline script element that checks for client hints and sets cookies
38 | * if they are not set then reloads the page if any cookie was set to an
39 | * inaccurate value.
40 | */
41 | export function ClientHintCheck({ nonce }: { nonce: string }) {
42 | const { revalidate } = useRevalidator()
43 | React.useEffect(
44 | () => subscribeToSchemeChange(() => revalidate()),
45 | [revalidate],
46 | )
47 |
48 | return (
49 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/docs/decisions/045-rr-auto-routes.md:
--------------------------------------------------------------------------------
1 | # React Router Auto Routes
2 |
3 | Date: 2025-10-15
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | Epic Stack has relied on `remix-flat-routes` to turn the `app/routes` directory
10 | into the route manifest React Router consumes at build time. The library helped
11 | us introduce file-system routing with predictable conventions and has served the
12 | project well. However, its hybrid convention with `+` suffix treats colocation
13 | as the default pattern, which can lead to less organized route structures as
14 | applications grow.
15 |
16 | We now have
17 | [`react-router-auto-routes`](https://github.com/kenn/react-router-auto-routes),
18 | designed to align with React Router's native conventions and APIs. It keeps the
19 | project close to upstream conventions, lowers the surface of custom tooling we
20 | need to maintain, and gives us a clearer migration path as React Router evolves.
21 |
22 | ## Decision
23 |
24 | We will replace `remix-flat-routes` with `react-router-auto-routes` for
25 | generating the application's route manifest. The new tool will become part of
26 | the build and dev pipelines, and any previous configuration specific to
27 | `remix-flat-routes` will be ported to the new conventions.
28 |
29 | ## Consequences
30 |
31 | - Auto route generation follows React Router's conventions, reducing the risk of
32 | breakage when we upgrade React Router in the future.
33 | - Contributors adopt the naming and organization rules defined by
34 | `react-router-auto-routes`, so we also updated documentation and examples to
35 | reflect the new folder semantics.
36 | - All existing functionality has been validated with `npm run validate` and
37 | works correctly with the new routing system.
38 |
--------------------------------------------------------------------------------
/docs/decisions/013-email-code.md:
--------------------------------------------------------------------------------
1 | # Email Verification Code
2 |
3 | Date: 2023-06-05
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | When a new user registers, we need to collect their email address so we can send
10 | them a password reset link if they forget their password. Applications may also
11 | need the email for other reasons, but whatever the case may be, we need their
12 | email address, and to reduce spam and user error, we want to verify the email as
13 | well.
14 |
15 | Currently, the Epic Stack will send the email with a link which the user can
16 | then click and start the onboarding process. This works fine, but it often means
17 | the user is left with a previous dead-end tab open which is kind of annoying
18 | (especially if they are on mobile and the email client opens the link in a
19 | different browser).
20 |
21 | An alternative to this is to include a verification code in the email and have
22 | the user enter that code into the application. This is a little more work for
23 | the user, but it's not too bad and it means that the user can continue their
24 | work from the same tab they started in.
25 |
26 | This also has implications if people want to add email verification for
27 | sensitive operations like password resets. If a code system is in place, it
28 | becomes much easier to add that verification to the password reset process as
29 | well.
30 |
31 | ## Decision
32 |
33 | We will support both options. The email will include a code and a link, giving
34 | the user the option between the two so they can select the one that works best
35 | for them in the situation.
36 |
37 | ## Consequences
38 |
39 | This requires a bit more work, but will ultimately be a better UX and will pave
40 | the way for other features in the future.
41 |
--------------------------------------------------------------------------------
/docs/secrets.md:
--------------------------------------------------------------------------------
1 | # Secrets
2 |
3 | Managing secrets in the Epic Stack is done using environment variables and the
4 | `fly secrets` command.
5 |
6 | > **Warning**: It is very important that you do NOT hard code any secrets in the
7 | > source code. Even if your app source is not public, there are a lot of reasons
8 | > this is dangerous and in the epic stack we default to creating source maps
9 | > which will reveal your hard coded secrets to the public. Read more about this
10 | > in [the source map decision document](./decisions/016-source-maps.md).
11 |
12 | ## Local development
13 |
14 | When you need to create a new secret, it's best to add a line to your
15 | `.env.example` file so folks know that secret is necessary. The value you put in
16 | here should be not real because this file is committed to the repository.
17 |
18 | To keep everything in line with the [guiding principle](./guiding-principles.md)
19 | of "Offline Development," you should also strive make it so whatever service
20 | you're interacting with can be mocked out using MSW in the `test/mocks`
21 | directory.
22 |
23 | You can also put the real value of the secret in `.env` which is `.gitignore`d
24 | so you can interact with the real service if you need to during development.
25 |
26 | ## Production secrets
27 |
28 | To publish a secret to your production and staging applications, you can use the
29 | `fly secrets set` command. For example, if you were integrating with the `tito`
30 | API, to set the `TITO_API_SECRET` secret, you would run the following command:
31 |
32 | ```sh
33 | fly secrets set TITO_API_SECRET=some_secret_value
34 | fly secrets set TITO_API_SECRET=some_secret_value --app [YOUR_STAGING_APP_NAME]
35 | ```
36 |
37 | This will redeploy your app with that environment variable set.
38 |
--------------------------------------------------------------------------------
/docs/decisions/001-typescript-only.md:
--------------------------------------------------------------------------------
1 | # TypeScript Only
2 |
3 | Date: 2023-05-08
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | The `create-remix` CLI allows users to select whether they want to use
10 | JavaScript instead of TypeScript. This will auto-convert everything to
11 | JavaScript.
12 |
13 | There is (currently) no way to control this behavior.
14 |
15 | Teams and individuals building modern web applications have many great reasons
16 | to build them with TypeScript.
17 |
18 | One of the challenges with TypeScript is getting it configured properly. This is
19 | not an issue with a stack which starts you off on the right foot without needing
20 | to configure anything.
21 |
22 | Another challenge with TypeScript is handling dependencies that are not written
23 | in TypeScript. This is increasingly becoming less of an issue with more and more
24 | dependencies being written in TypeScript.
25 |
26 | ## Decision
27 |
28 | We strongly advise the use of TypeScript even for simple projects and those
29 | worked on by single developers. So instead of working on making this project
30 | work with the JavaScript option of the `create-remix` CLI, we've decided to
31 | throw an error informing the user to try again and select the TypeScript option.
32 |
33 | We've also made the example script in the `README.md` provide a selected option
34 | of `--typescript` so folks shouldn't even be asked unless they leave off that
35 | flag in which case our error will be thrown.
36 |
37 | ## Consequences
38 |
39 | This makes the initial experience not great for folks using JavaScript.
40 | Hopefully the Remix CLI will eventually allow us to have more control over
41 | whether that question is asked.
42 |
43 | This also may anger some folks who really don't like TypeScript. For those
44 | folks, feel free to fork the starter.
45 |
--------------------------------------------------------------------------------
/docs/decisions/046-remove-path-aliases.md:
--------------------------------------------------------------------------------
1 | # Remove Path Aliases
2 |
3 | Date: 2025-10-23
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | In [031-imports](./031-imports.md), we implemented path aliases using both the
10 | `"imports"` field in `package.json` and the `paths` field in `tsconfig.json`.
11 | This was done as a temporary solution because TypeScript didn't natively support
12 | the `"imports"` field, requiring us to maintain both configurations to get
13 | autocomplete and type checking.
14 |
15 | However, TypeScript has now added native support for the `"imports"` field in
16 | `package.json` (as referenced in the original decision's "yet" link). This means
17 | we no longer need the `paths` configuration in `tsconfig.json` to get proper
18 | TypeScript support for our imports.
19 |
20 | ## Decision
21 |
22 | We're removing the path aliases configuration from `tsconfig.json` and relying
23 | solely on the `"imports"` field in `package.json`. This simplifies our
24 | configuration and aligns with the standard Node.js approach.
25 |
26 | The `"imports"` field will continue to work as before, providing the same import
27 | resolution functionality, but now with full TypeScript support without requiring
28 | duplicate configuration.
29 |
30 | ## Consequences
31 |
32 | - **Simplified configuration**: We no longer need to maintain both
33 | `package.json` imports and `tsconfig.json` paths
34 | - **Standard compliance**: We're now using the standard Node.js approach without
35 | TypeScript-specific workarounds
36 | - **Reduced maintenance**: One less configuration to keep in sync
37 | - **Better tooling support**: TypeScript now natively understands the
38 | `"imports"` field, providing better IDE support
39 |
40 | This supersedes [031-imports](./031-imports.md) as the current approach for
41 | handling imports in the Epic Stack.
42 |
--------------------------------------------------------------------------------
/docs/decisions/037-generated-internal-command.md:
--------------------------------------------------------------------------------
1 | # Generated Internal Command Env Var
2 |
3 | Date: 2024-06-19
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | There are use cases where your application needs to talk to itself over HTTP.
10 | One example of this is when a read-replica instance needs to trigger an update
11 | to the cache in the primary instance. This can be done by making an HTTP request
12 | to the primary instance.
13 |
14 | To secure this communication, we can use a secret token that is shared between
15 | the instances. This token can be stored in the environment variables of the
16 | instances.
17 |
18 | Originally, this token was manually generated once and set as a secret in the
19 | Fly app. This token was then used in the application code to authenticate the
20 | requests.
21 |
22 | However, this manual process is error-prone and can lead to security issues if
23 | the token is leaked.
24 |
25 | An alternative is to generate the token in the Dockerfile and set it as an
26 | environment variable in the Fly app. This way, the token is generated
27 | automatically and is unique for each deployment.
28 |
29 | One drawback to this is during the deployment process, an old replica might
30 | still be running with the old token. This can cause issues if the new replica is
31 | expecting the new token. However, this should be short-lived and it's also
32 | possible the read replica is running out-of-date code anyway so it may be to our
33 | benefit anyway.
34 |
35 | ## Decision
36 |
37 | We will generate the internal command token in the Dockerfile and set it as an
38 | environment variable in the Fly app.
39 |
40 | ## Consequences
41 |
42 | We'll need to remove the steps during initial setup and the documentation
43 | instructions. This will simplify the setup process and reduce the risk of
44 | security issues due to leaked tokens.
45 |
--------------------------------------------------------------------------------
/docs/decisions/042-node-sqlite.md:
--------------------------------------------------------------------------------
1 | # Node's Built-in SQLite Support
2 |
3 | Date: 2025-03-22
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | The Epic Stack previously used `better-sqlite3` as the SQLite driver for
10 | Node.js. While `better-sqlite3` is a mature and feature-rich library, Node.js
11 | has recently added built-in SQLite support through the `node:sqlite3` module.
12 | This built-in support provides a simpler, more maintainable solution that
13 | doesn't require additional dependencies.
14 |
15 | The built-in SQLite support in Node.js is based on the same underlying SQLite
16 | engine, ensuring compatibility with our existing database schema and queries. It
17 | also provides a Promise-based API that aligns well with modern JavaScript
18 | practices.
19 |
20 | ## Decision
21 |
22 | We will switch from `better-sqlite3` to Node's built-in SQLite support
23 | (`node:sqlite3`) for the following reasons:
24 |
25 | 1. Reduced dependencies - one less package to maintain and update
26 | 2. Native integration with Node.js - better long-term support and compatibility
27 | 3. Simpler setup - no need to handle native module compilation
28 | 4. Official Node.js support - better reliability and future-proofing
29 |
30 | ## Consequences
31 |
32 | This change will require:
33 |
34 | 1. Updating database connection code to use the new API
35 | 2. Removing `better-sqlite3` from package.json and lockfile
36 |
37 | The migration should be relatively straightforward since both libraries use the
38 | same underlying SQLite engine. The main changes will be in the API usage
39 | patterns rather than the database functionality itself.
40 |
41 | This change aligns with our goal of simplifying the stack while maintaining
42 | robust functionality. The built-in SQLite support provides all the features we
43 | need without the overhead of an additional dependency.
44 |
--------------------------------------------------------------------------------
/docs/testing.md:
--------------------------------------------------------------------------------
1 | # Testing
2 |
3 | ## Playwright
4 |
5 | We use Playwright for our End-to-End tests in this project. You'll find those in
6 | the `tests` directory. As you make changes, add to an existing file or create a
7 | new file in the `tests` directory to test your changes.
8 |
9 | To run these tests in development, run `npm run test:e2e:dev` which will start
10 | the dev server for the app and run Playwright on it.
11 |
12 | We have a fixture for testing authenticated features without having to go
13 | through the login flow:
14 |
15 | ```ts
16 | test('my test', async ({ page, login }) => {
17 | const user = await login()
18 | // you are now logged in
19 | })
20 | ```
21 |
22 | We also auto-delete the user at the end of your test. That way, we can keep your
23 | local db clean and keep your tests isolated from one another.
24 |
25 | ## Vitest
26 |
27 | For lower level tests of utilities and individual components, we use `vitest`.
28 | We have DOM-specific assertion helpers via
29 | [`@testing-library/jest-dom`](https://testing-library.com/jest-dom).
30 |
31 | ## Type Checking
32 |
33 | This project uses TypeScript. It's recommended to get TypeScript set up for your
34 | editor to get a really great in-editor experience with type checking and
35 | auto-complete. To run type checking across the whole project, run
36 | `npm run typecheck`.
37 |
38 | ## Linting
39 |
40 | This project uses ESLint for linting. That is configured in `.eslintrc.js`.
41 |
42 | ## Formatting
43 |
44 | We use [Prettier](https://prettier.io/) for auto-formatting in this project.
45 | It's recommended to install an editor plugin (like the
46 | [VSCode Prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode))
47 | to get auto-formatting on save. There's also a `npm run format` script you can
48 | run to format all files in the project.
49 |
--------------------------------------------------------------------------------
/app/utils/connections.tsx:
--------------------------------------------------------------------------------
1 | import { Form } from 'react-router'
2 | import { z } from 'zod'
3 | import { Icon } from '#app/components/ui/icon.tsx'
4 | import { StatusButton } from '#app/components/ui/status-button.tsx'
5 | import { useIsPending } from './misc.tsx'
6 |
7 | export const GITHUB_PROVIDER_NAME = 'github'
8 | // to add another provider, set their name here and add it to the providerNames below
9 |
10 | export const providerNames = [GITHUB_PROVIDER_NAME] as const
11 | export const ProviderNameSchema = z.enum(providerNames)
12 | export type ProviderName = z.infer
13 |
14 | export const providerLabels: Record = {
15 | [GITHUB_PROVIDER_NAME]: 'GitHub',
16 | } as const
17 |
18 | export const providerIcons: Record = {
19 | [GITHUB_PROVIDER_NAME]: ,
20 | } as const
21 |
22 | export function ProviderConnectionForm({
23 | redirectTo,
24 | type,
25 | providerName,
26 | }: {
27 | redirectTo?: string | null
28 | type: 'Connect' | 'Login' | 'Signup'
29 | providerName: ProviderName
30 | }) {
31 | const label = providerLabels[providerName]
32 | const formAction = `/auth/${providerName}`
33 | const isPending = useIsPending({ formAction })
34 | return (
35 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/app/components/search-bar.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from 'react'
2 | import { Form, useSearchParams, useSubmit } from 'react-router'
3 | import { useDebounce, useIsPending } from '#app/utils/misc.tsx'
4 | import { Icon } from './ui/icon.tsx'
5 | import { Input } from './ui/input.tsx'
6 | import { Label } from './ui/label.tsx'
7 | import { StatusButton } from './ui/status-button.tsx'
8 |
9 | export function SearchBar({
10 | status,
11 | autoFocus = false,
12 | autoSubmit = false,
13 | }: {
14 | status: 'idle' | 'pending' | 'success' | 'error'
15 | autoFocus?: boolean
16 | autoSubmit?: boolean
17 | }) {
18 | const id = useId()
19 | const [searchParams] = useSearchParams()
20 | const submit = useSubmit()
21 | const isSubmitting = useIsPending({
22 | formMethod: 'GET',
23 | formAction: '/users',
24 | })
25 |
26 | const handleFormChange = useDebounce(async (form: HTMLFormElement) => {
27 | await submit(form)
28 | }, 400)
29 |
30 | return (
31 |
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/app/routes/resources/download-user-data.tsx:
--------------------------------------------------------------------------------
1 | import { requireUserId } from '#app/utils/auth.server.ts'
2 | import { prisma } from '#app/utils/db.server.ts'
3 | import { getDomainUrl, getNoteImgSrc, getUserImgSrc } from '#app/utils/misc.tsx'
4 | import { type Route } from './+types/download-user-data.ts'
5 |
6 | export async function loader({ request }: Route.LoaderArgs) {
7 | const userId = await requireUserId(request)
8 | const user = await prisma.user.findUniqueOrThrow({
9 | where: { id: userId },
10 | // this is one of the *few* instances where you can use "include" because
11 | // the goal is to literally get *everything*. Normally you should be
12 | // explicit with "select". We're using select for images because we don't
13 | // want to send back the entire blob of the image. We'll send a URL they can
14 | // use to download it instead.
15 | include: {
16 | image: {
17 | select: {
18 | id: true,
19 | createdAt: true,
20 | updatedAt: true,
21 | objectKey: true,
22 | },
23 | },
24 | notes: {
25 | include: {
26 | images: {
27 | select: {
28 | id: true,
29 | createdAt: true,
30 | updatedAt: true,
31 | objectKey: true,
32 | },
33 | },
34 | },
35 | },
36 | password: false, // <-- intentionally omit password
37 | sessions: true,
38 | roles: true,
39 | },
40 | })
41 |
42 | const domain = getDomainUrl(request)
43 |
44 | return Response.json({
45 | user: {
46 | ...user,
47 | image: user.image
48 | ? {
49 | ...user.image,
50 | url: domain + getUserImgSrc(user.image.objectKey),
51 | }
52 | : null,
53 | notes: user.notes.map((note) => ({
54 | ...note,
55 | images: note.images.map((image) => ({
56 | ...image,
57 | url: domain + getNoteImgSrc(image.objectKey),
58 | })),
59 | })),
60 | },
61 | })
62 | }
63 |
--------------------------------------------------------------------------------
/docs/image-optimization.md:
--------------------------------------------------------------------------------
1 | # Image Optimization
2 |
3 | The Epic Stack uses [openimg](https://github.com/andrelandgraf/openimg) to
4 | optimize images on demand, introduced via
5 | [this decision doc](./decisions/041-image-optimization.md).
6 |
7 | ## Server Part
8 |
9 | The [/resources/images](../app/routes/resources/images.tsx) endpoint accepts the
10 | search parameters `src`, `w` (width), `h` (height), `format`, and `fit` to
11 | perform image transformations and serve optimized variants. The transformations
12 | are performed with `sharp`, and the optimized images are cached in
13 | `./data/images` on the filesystem and via HTTP caching. All transformations
14 | happen via stream processing, so images are never loaded fully into memory at
15 | once.
16 |
17 | ## Client Part
18 |
19 | On the client side, the `Img` React component from openimg/react is used to
20 | query the [/resources/images](../app/routes/resources/images.tsx) endpoint with
21 | the appropriate query parameters, including the source image string. The
22 | component renders a picture element that requests modern formats and sets
23 | attributes such as `fetchpriority`, `loading`, and `decoding` to optimize image
24 | loading. It also computes `srcset` and `sizes` based on the provided `width` and
25 | `height` props. Use the `isAboveFold` prop on the `Img` component to priotize
26 | images that should load immediately.
27 |
28 | ## Image Sources
29 |
30 | If you want to add a new image storage location, update the
31 | [/resources/images](../app/routes/resources/images.tsx) endpoint and modify
32 | `getSource` and `allowlistedOrigins` to instruct openimg on how to retrieve the
33 | source images from the new location. Currently, the endpoint uses fetch requests
34 | to retrieve user images from the resource route endpoints and the filesystem to
35 | retrieve static application assets from the public and assets folders.
36 |
--------------------------------------------------------------------------------
/other/svg-icons/update.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/docs/decisions/020-icons.md:
--------------------------------------------------------------------------------
1 | # Icons
2 |
3 | Date: 2023-06-28
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | Icons are a critical part to every application. It helps users quickly identify
10 | different actions they can take and the meaning of different elements on the
11 | page. It's pretty well accepted that SVGs are the way to go with icons, but
12 | there are a few different options for how to go about doing this.
13 |
14 | Because the Epic Stack is using React, it may feel obvious to just use a
15 | component per icon and inline the SVG in the component. This is fine, but it's
16 | sub-optimal. I'm not going to spend time explaining why, because
17 | [this article does a great job of that](https://benadam.me/thoughts/react-svg-sprites/).
18 |
19 | SVG sprites are no less ergonomic than inline SVGs in React because in either
20 | case you need to do some sort of transformation of the SVG to make it useable in
21 | the application. If you inline SVGs, you have [SVGR](https://react-svgr.com/) to
22 | automate this process. So if we can automate the process of creating and
23 | consuming a sprite, we're in a fine place.
24 |
25 | And [rmx-cli](https://github.com/kiliman/rmx-cli) has support for automating the
26 | creation of an SVG sprite.
27 |
28 | One drawback to sprites is you don't typically install a library of icons and
29 | then use them like regular components. You do need to have a process for adding
30 | these to the sprite. And you wouldn't want to add every possible icon as there's
31 | no "tree-shaking" for sprites.
32 |
33 | ## Decision
34 |
35 | Setup the project to use SVG sprites with `rmx-cli`.
36 |
37 | ## Consequences
38 |
39 | We'll need to document the process of adding SVGs. It's still possible to simply
40 | install a library of icons and use them as components if you're ok with the
41 | trade-offs of that approach. But the default in the starter will be to use
42 | sprites.
43 |
--------------------------------------------------------------------------------
/docs/getting-started.md:
--------------------------------------------------------------------------------
1 | # Getting Started with the Epic Stack
2 |
3 | The Epic Stack is a [Remix Stack](https://remix.run/stacks). To start your Epic
4 | Stack, run the following [`npx`](https://docs.npmjs.com/cli/v9/commands/npx)
5 | command using the current LTS version of Node.js:
6 |
7 | ```sh
8 | npx epicli new
9 | ```
10 |
11 | This will prompt you for a project name (the name of the directory to put your
12 | project). Once you've selected that, the CLI will start the setup process.
13 |
14 | Once the setup is complete, go ahead and `cd` into the new project directory and
15 | run `npm run dev` to get the app started.
16 |
17 | Check the project README.md for instructions on getting the app deployed. You'll
18 | want to get this done early in the process to make sure you're all set up
19 | properly.
20 |
21 | If you'd like to skip some of the setup steps, you can set the following
22 | environment variables when you run the script:
23 |
24 | - `SKIP_SETUP` - skips running `npm run setup`
25 | - `SKIP_FORMAT` - skips running `npm run format`
26 | - `SKIP_DEPLOYMENT` - skips deployment setup
27 |
28 | So, if you enabled all of these it would be:
29 |
30 | ```sh
31 | SKIP_SETUP=true SKIP_FORMAT=true SKIP_DEPLOYMENT=true npx epicli new
32 | ```
33 |
34 | Or, on windows:
35 |
36 | ```
37 | set SKIP_SETUP=true && set SKIP_FORMAT=true && set SKIP_DEPLOYMENT=true && npx epicli new
38 | ```
39 |
40 | ## Development
41 |
42 | - Initial setup:
43 |
44 | ```sh
45 | npm run setup
46 | ```
47 |
48 | - Seed database:
49 |
50 | ```sh
51 | npx prisma@6 db seed
52 | ```
53 |
54 | - Start dev server:
55 |
56 | ```sh
57 | npm run dev
58 | ```
59 |
60 | This starts your app in development mode, rebuilding assets on file changes.
61 |
62 | The database seed script creates a new user with some data you can use to get
63 | started:
64 |
65 | - Username: `kody`
66 | - Password: `kodylovesyou`
67 |
--------------------------------------------------------------------------------
/app/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from '@radix-ui/react-slot'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 | import * as React from 'react'
4 |
5 | import { cn } from '#app/utils/misc.tsx'
6 |
7 | const buttonVariants = cva(
8 | 'ring-ring ring-offset-background inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-2 outline-hidden transition-colors focus-within:ring-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground hover:bg-primary/80',
13 | destructive:
14 | 'bg-destructive text-destructive-foreground hover:bg-destructive/80',
15 | outline:
16 | 'border-input bg-background hover:bg-accent hover:text-accent-foreground border',
17 | secondary:
18 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
19 | ghost: 'hover:bg-accent hover:text-accent-foreground',
20 | link: 'text-primary underline-offset-4 hover:underline',
21 | },
22 | size: {
23 | default: 'h-10 px-4 py-2',
24 | wide: 'px-24 py-5',
25 | sm: 'h-9 rounded-md px-3',
26 | lg: 'h-11 rounded-md px-8',
27 | pill: 'px-12 py-3 leading-3',
28 | icon: 'size-10',
29 | },
30 | },
31 | defaultVariants: {
32 | variant: 'default',
33 | size: 'default',
34 | },
35 | },
36 | )
37 |
38 | export type ButtonVariant = VariantProps
39 |
40 | const Button = ({
41 | className,
42 | variant,
43 | size,
44 | asChild = false,
45 | ...props
46 | }: React.ComponentProps<'button'> &
47 | ButtonVariant & {
48 | asChild?: boolean
49 | }) => {
50 | const Comp = asChild ? Slot : 'button'
51 | return (
52 |
57 | )
58 | }
59 |
60 | export { Button, buttonVariants }
61 |
--------------------------------------------------------------------------------
/docs/decisions/035-remove-csrf.md:
--------------------------------------------------------------------------------
1 | # Remove CSRF
2 |
3 | Date: 2024-01-29
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | Read more about the original CSRF decision in [032-csrf.md](./032-csrf.md).
10 |
11 | Modern browser support for `SameSite: Lax` and our use of that for all cookies
12 | means that cookies are not sent on cross-site requests. This means that CSRF
13 | protection is not needed for our cookies.
14 |
15 | There are however a few exceptions which motivated the original inclusion of
16 | CSRF:
17 |
18 | - GET requests are not protected by `SameSite: Lax` and so are vulnerable to
19 | CSRF attacks. However, we do not have any GET endpoints that perform mutations
20 | on the server. The only GET endpoints we have are for fetching data and so
21 | there is no meaningful CSRF attack that could be performed.
22 | - The `POST /login` endpoint does not require cookies at all and so is
23 | technically vulnerable to CSRF attacks. But anyone who could exploit this
24 | endpoint would have to know the user's username and password anyway in which
25 | case they could just log in as the user directly.
26 |
27 | With the addition of the honeypot field to prevent bots from submitting the
28 | login form, the lack of vulnerability due to the cookie configuration, and the
29 | fact that CSRF adds a bit of complexity to the code, it just doesn't seem worth
30 | it to keep CSRF tokens around.
31 |
32 | ## Decision
33 |
34 | Remove CSRF tokens from the codebase.
35 |
36 | ## Consequences
37 |
38 | If someone adds a GET request which does mutate state, then this could be an
39 | issue. However, a CSRF token could be added back for that specific mutation.
40 | Also, if the cookie configuration is changed from `Lax` to `None` (useful in
41 | various contexts, but certainly not a good default), then CSRF tokens would need
42 | to be added back. So we'll add a comment to the code for configuring the cookie
43 | mentioning this.
44 |
--------------------------------------------------------------------------------
/other/svg-icons/question-mark-circled.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
--------------------------------------------------------------------------------
/app/utils/user-validation.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const USERNAME_MIN_LENGTH = 3
4 | export const USERNAME_MAX_LENGTH = 20
5 |
6 | export const UsernameSchema = z
7 | .string({ required_error: 'Username is required' })
8 | .min(USERNAME_MIN_LENGTH, { message: 'Username is too short' })
9 | .max(USERNAME_MAX_LENGTH, { message: 'Username is too long' })
10 | .regex(/^[a-zA-Z0-9_]+$/, {
11 | message: 'Username can only include letters, numbers, and underscores',
12 | })
13 | // users can type the username in any case, but we store it in lowercase
14 | .transform((value) => value.toLowerCase())
15 |
16 | export const PasswordSchema = z
17 | .string({ required_error: 'Password is required' })
18 | .min(6, { message: 'Password is too short' })
19 | // NOTE: bcrypt has a limit of 72 bytes (which should be plenty long)
20 | // https://github.com/epicweb-dev/epic-stack/issues/918
21 | .refine((val) => new TextEncoder().encode(val).length <= 72, {
22 | message: 'Password is too long',
23 | })
24 |
25 | export const NameSchema = z
26 | .string({ required_error: 'Name is required' })
27 | .min(3, { message: 'Name is too short' })
28 | .max(40, { message: 'Name is too long' })
29 |
30 | export const EmailSchema = z
31 | .string({ required_error: 'Email is required' })
32 | .email({ message: 'Email is invalid' })
33 | .min(3, { message: 'Email is too short' })
34 | .max(100, { message: 'Email is too long' })
35 | // users can type the email in any case, but we store it in lowercase
36 | .transform((value) => value.toLowerCase())
37 |
38 | export const PasswordAndConfirmPasswordSchema = z
39 | .object({ password: PasswordSchema, confirmPassword: PasswordSchema })
40 | .superRefine(({ confirmPassword, password }, ctx) => {
41 | if (confirmPassword !== password) {
42 | ctx.addIssue({
43 | path: ['confirmPassword'],
44 | code: 'custom',
45 | message: 'The passwords must match',
46 | })
47 | }
48 | })
49 |
--------------------------------------------------------------------------------
/docs/icons.md:
--------------------------------------------------------------------------------
1 | # Icons
2 |
3 | The Epic Stack uses SVG sprites for
4 | [optimal icon performance](https://benadam.me/thoughts/react-svg-sprites/).
5 | You'll find raw SVGs in the `./other/svg-icons` directory. These are then
6 | compiled into a sprite using the
7 | [`vite-plugin-icons-spritesheet`](https://github.com/jacobparis-insiders/vite-plugin-icons-spritesheet)
8 | plugin which generates the `app/components/ui/icons/sprite.svg` file and the
9 | accompanying `types.ts` file that allows Typescript to pick up the names of the
10 | icons.
11 |
12 | You can use [Sly](https://github.com/jacobparis-insiders/sly/tree/main/cli) to
13 | add new icons from the command line.
14 |
15 | To add the `trash`, `pencil-1`, and `avatar` icons, run:
16 |
17 | ```sh
18 | npx sly add @radix-ui/icons trash pencil-1 avatar
19 | ```
20 |
21 | If you don't specify the icons, Sly will show an interactive list of all the
22 | icons available in the `@radix-ui/icons` collection and let you select the ones
23 | you want to add.
24 |
25 | Sly has been configured in the Epic Stack to automatically add the icons to the
26 | `./other/svg-icons` directory, so there are no extra steps to take. You can see
27 | the configuration in the `./other/sly/sly.json` file.
28 |
29 | The SVGs used by default in the Epic Stack come from
30 | [icons.radix-ui.com](https://icons.radix-ui.com/). You can download additional
31 | SVG icons from there, or provide your own. Once you've added new files in the
32 | directory, run `npm run build` and you can then use the `Icon` component to
33 | render it. The `icon` prop is the name of the file without the `.svg` extension.
34 | We recommend using `kebab-case` filenames rather than `PascalCase` to avoid
35 | casing issues with different operating systems.
36 |
37 | By default, all the icons will have a height and width of `1em` so they should
38 | match the font-size of the text they're next to. You can also customize the size
39 | using the `size` prop.
40 |
--------------------------------------------------------------------------------
/.cursor/rules/avoid-use-effect.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description:
3 | globs: *.tsx,*.jsx
4 | alwaysApply: false
5 | ---
6 | ### Avoid useEffect
7 |
8 | [You Might Not Need `useEffect`](https://react.dev/learn/you-might-not-need-an-effect)
9 |
10 | Instead of using `useEffect`, use ref callbacks, event handlers with
11 | `flushSync`, css, `useSyncExternalStore`, etc.
12 |
13 | ```tsx
14 | // This example was ripped from the docs:
15 | // ✅ Good
16 | function ProductPage({ product, addToCart }) {
17 | function buyProduct() {
18 | addToCart(product)
19 | showNotification(`Added ${product.name} to the shopping cart!`)
20 | }
21 |
22 | function handleBuyClick() {
23 | buyProduct()
24 | }
25 |
26 | function handleCheckoutClick() {
27 | buyProduct()
28 | navigateTo('/checkout')
29 | }
30 | // ...
31 | }
32 |
33 | useEffect(() => {
34 | setCount(count + 1)
35 | }, [count])
36 |
37 | // ❌ Avoid
38 | function ProductPage({ product, addToCart }) {
39 | useEffect(() => {
40 | if (product.isInCart) {
41 | showNotification(`Added ${product.name} to the shopping cart!`)
42 | }
43 | }, [product])
44 |
45 | function handleBuyClick() {
46 | addToCart(product)
47 | }
48 |
49 | function handleCheckoutClick() {
50 | addToCart(product)
51 | navigateTo('/checkout')
52 | }
53 | // ...
54 | }
55 | ```
56 |
57 | There are a lot more examples in the docs. `useEffect` is not banned or
58 | anything. There are just better ways to handle most cases.
59 |
60 | Here's an example of a situation where `useEffect` is appropriate:
61 |
62 | ```tsx
63 | // ✅ Good
64 | useEffect(() => {
65 | const controller = new AbortController()
66 |
67 | window.addEventListener(
68 | 'keydown',
69 | (event: KeyboardEvent) => {
70 | if (event.key !== 'Escape') return
71 |
72 | // do something based on escape key being pressed
73 | },
74 | { signal: controller.signal },
75 | )
76 |
77 | return () => {
78 | controller.abort()
79 | }
80 | }, [])
81 | ```
82 |
--------------------------------------------------------------------------------
/docs/decisions/038-remove-cleanup-db.md:
--------------------------------------------------------------------------------
1 | # Remove Cleanup DB
2 |
3 | Date: 2024-10-28
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | We have a utility called `cleanupDb` that removes all tables from the database
10 | except for prisma migration tables. The reference to prisma migration tables is
11 | unfortunate because those are an implementation detail that we should not have
12 | to think about.
13 |
14 | The goal of `cleanupDb` was to make it easy for tests to reset the database
15 | without having to run `prisma migrate reset` which is too slow for lower level
16 | tests.
17 |
18 | We also used `cleanupDb` in the seed file to reset the database before seeding
19 | it.
20 |
21 | However, after a lot of work on the tests, we found a much simpler solution to
22 | resetting the database between tests: simply copy/paste the `base.db` file
23 | (which is a fresh database) to `test.db` before each test. We were already doing
24 | this before all the tests. It takes nanoseconds and is much simpler.
25 |
26 | For the seed script, it's nice to have the database be completely reset when
27 | running `prisma db seed` (in fact, our seed expects the database to be empty),
28 | but you can get the same behavior as our current `seed` with a fresh database by
29 | running `prisma migrate reset` (which runs the seed script after resetting the
30 | database).
31 |
32 | It would be nice to ditch the implementation detail of prisma's tables, so we'd
33 | like to remove this utility.
34 |
35 | ## Decision
36 |
37 | Remove the `cleanupDb` utility and update our CI to run `prisma migrate reset`
38 | instead of `prisma db seed`.
39 |
40 | ## Consequences
41 |
42 | Running `prisma db seed` will fail because the seed script expects the database
43 | to be empty. We could address this by using upsert or something, but really
44 | people should just run `prisma migrate reset` to seed the database (which is
45 | effectively what we used to do before removing `cleanupDb`).
46 |
--------------------------------------------------------------------------------
/tests/mocks/utils.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import { fileURLToPath } from 'node:url'
3 | import fsExtra from 'fs-extra'
4 | import { z } from 'zod'
5 |
6 | const __dirname = path.dirname(fileURLToPath(import.meta.url))
7 | const fixturesDirPath = path.join(__dirname, '..', 'fixtures')
8 |
9 | export async function readFixture(subdir: string, name: string) {
10 | return fsExtra.readJSON(path.join(fixturesDirPath, subdir, `${name}.json`))
11 | }
12 |
13 | export async function createFixture(
14 | subdir: string,
15 | name: string,
16 | data: unknown,
17 | ) {
18 | const dir = path.join(fixturesDirPath, subdir)
19 | await fsExtra.ensureDir(dir)
20 | return fsExtra.writeJSON(path.join(dir, `./${name}.json`), data)
21 | }
22 |
23 | export const EmailSchema = z.object({
24 | to: z.string(),
25 | from: z.string(),
26 | subject: z.string(),
27 | text: z.string(),
28 | html: z.string(),
29 | })
30 |
31 | export async function writeEmail(rawEmail: unknown) {
32 | const email = EmailSchema.parse(rawEmail)
33 | await createFixture('email', email.to, email)
34 | return email
35 | }
36 |
37 | export async function requireEmail(recipient: string) {
38 | const email = await readEmail(recipient)
39 | if (!email) throw new Error(`Email to ${recipient} not found`)
40 | return email
41 | }
42 |
43 | export async function readEmail(recipient: string) {
44 | try {
45 | const email = await readFixture('email', recipient)
46 | return EmailSchema.parse(email)
47 | } catch (error) {
48 | console.error(`Error reading email`, error)
49 | return null
50 | }
51 | }
52 |
53 | export function requireHeader(headers: Headers, header: string) {
54 | if (!headers.has(header)) {
55 | const headersString = JSON.stringify(
56 | Object.fromEntries(headers.entries()),
57 | null,
58 | 2,
59 | )
60 | throw new Error(
61 | `Header "${header}" required, but not found in ${headersString}`,
62 | )
63 | }
64 | return headers.get(header)
65 | }
66 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Epic Stack Documentation
2 |
3 | The goal of The Epic Stack is to provide solid opinions for teams to hit the
4 | ground running on their web applications.
5 |
6 | We recommend you watch Kent's introduction to the Epic Stack to get an
7 | understanding of the "why" behind the Stack:
8 |
9 | [](https://www.epicweb.dev/talks/the-epic-stack)
10 |
11 | More of a reader? Read [the announcement post](https://epicweb.dev/epic-stack)
12 | or
13 | [an AI generated summary of the video](https://www.summarize.tech/www.youtube.com/watch?v=yMK5SVRASxM).
14 |
15 | This stack is still under active development. Documentation will rapidly improve
16 | in the coming weeks. Stay tuned!
17 |
18 | # Top Pages
19 |
20 | - [Getting Started](./getting-started.md) - Instructions for how to get started
21 | with the Epic Stack.
22 | - [Features](./features.md) - List of features the Epic Stack provides out of
23 | the box.
24 | - [Deployment](./deployment.md) - If you skip the deployment step when starting
25 | your app, these are the manual steps you can follow to get things up and
26 | running.
27 | - [Decisions](./decisions/README.md) - The reasoning behind various decisions
28 | made for the Epic Stack. A good historical record.
29 | - [Guiding Principles](./guiding-principles.md) - The guiding principles behind
30 | the Epic Stack.
31 | - [Examples](./examples.md) - Examples of the Epic Stack with various tools.
32 | Most new feature requests people have for the Epic Stack start as examples
33 | before being integrated into the framework.
34 | - [Managing Updates](./managing-updates.md) - How to manage updates to the Epic
35 | Stack for both the generated stack code as well as npm dependencies.
36 |
--------------------------------------------------------------------------------
/docs/toasts.md:
--------------------------------------------------------------------------------
1 | # Toasts
2 |
3 | Toast messages are great ways to temporarily call someone's attention to
4 | something. They are often used to notify users of a successful or failed action.
5 |
6 | 
7 |
8 | There are utilities in the Epic Stack for toast notifications.
9 |
10 | This is managed by a special session using a concept called "flash data" which
11 | is a temporary session value that is only available for the next request. This
12 | is a great way to pass data to the next request without having to worry about
13 | the data persisting in the session. And you don't have to worry about managing
14 | state either. It all just lives in the cookie.
15 |
16 | The primary utility you'll use for redirecting with toast notifications is
17 | `redirectWithToast` from `app/utils/toast.server.ts`. Here's a simple example of
18 | using this:
19 |
20 | ```tsx
21 | return redirectWithToast(`/users/${note.owner.username}/notes/${note.id}`, {
22 | description: id ? 'Note updated' : 'Note created',
23 | })
24 | ```
25 |
26 | This accepts an additional argument for other `ResponseInit` options so you can
27 | set other headers, etc.
28 |
29 | If you don't wish to redirect, you could use the underlying `createToastHeaders`
30 | directly:
31 |
32 | ```tsx
33 | return json(
34 | { success: true },
35 | {
36 | headers: await createToastHeaders({
37 | description: 'Note updated',
38 | type: 'success',
39 | }),
40 | },
41 | )
42 | ```
43 |
44 | And if you need to set multiple headers, you can use the `combineHeaders`
45 | utility from `app/utils/misc.tsx`:
46 |
47 | ```tsx
48 | return json(
49 | { success: true },
50 | {
51 | headers: combineHeaders(
52 | await createToastHeaders({
53 | toast: {
54 | description: 'Note updated',
55 | type: 'success',
56 | },
57 | }),
58 | { 'x-foo': 'bar' },
59 | ),
60 | },
61 | )
62 | ```
63 |
--------------------------------------------------------------------------------
/docs/guiding-principles.md:
--------------------------------------------------------------------------------
1 | # Epic Stack Guiding Principles
2 |
3 | Decisions about the Epic Stack should be guided by the following guiding
4 | principles:
5 |
6 | - **Limit Services:** If we can reasonably build, deploy, maintain it ourselves,
7 | do it. Additionally, if we can reasonably run it within our app instance, do
8 | it. This saves on cost and reduces complexity.
9 | - **Include Only Most Common Use Cases:** As a project generator, it is expected
10 | that some code will necessarily be deleted, but implementing support for every
11 | possible type of feature is literally impossible. _The starter app is not
12 | docs_, so to demonstrate a feature or give an example, put that in the docs
13 | instead of in the starter app.
14 | - **Minimize Setup Friction:** Try to keep the amount of time it takes to get an
15 | app to production as small as possible. If a service is necessary, see if we
16 | can defer signup for that service until its services are actually required.
17 | Additionally, while the target audience for this stack is apps that need scale
18 | you have to pay for, we try to fit within the free tier of any services used
19 | during the exploration phase.
20 | - **Optimize for Adaptability:** While we feel great about our opinions,
21 | ever-changing product requirements sometimes necessitate swapping trade-offs.
22 | So while we try to keep things simple, we want to ensure teams using the Epic
23 | Stack are able to adapt by switching between third party services to
24 | custom-built services and vice-versa.
25 | - **Only one way:** Avoid providing more than one way to do the same thing. This
26 | applies to both the pre-configured code and the documentation.
27 | - **Offline Development:** We want to enable offline development as much as
28 | possible. Naturally we need to use third party services for some things (like
29 | email), but for those we'll strive to provide a way to mock them out for local
30 | development.
31 |
--------------------------------------------------------------------------------
/app/utils/toast.server.ts:
--------------------------------------------------------------------------------
1 | import { createId as cuid } from '@paralleldrive/cuid2'
2 | import { createCookieSessionStorage, redirect } from 'react-router'
3 | import { z } from 'zod'
4 | import { combineHeaders } from './misc.tsx'
5 |
6 | export const toastKey = 'toast'
7 |
8 | const ToastSchema = z.object({
9 | description: z.string(),
10 | id: z.string().default(() => cuid()),
11 | title: z.string().optional(),
12 | type: z.enum(['message', 'success', 'error']).default('message'),
13 | })
14 |
15 | export type Toast = z.infer
16 | export type ToastInput = z.input
17 |
18 | export const toastSessionStorage = createCookieSessionStorage({
19 | cookie: {
20 | name: 'en_toast',
21 | sameSite: 'lax',
22 | path: '/',
23 | httpOnly: true,
24 | secrets: process.env.SESSION_SECRET.split(','),
25 | secure: process.env.NODE_ENV === 'production',
26 | },
27 | })
28 |
29 | export async function redirectWithToast(
30 | url: string,
31 | toast: ToastInput,
32 | init?: ResponseInit,
33 | ) {
34 | return redirect(url, {
35 | ...init,
36 | headers: combineHeaders(init?.headers, await createToastHeaders(toast)),
37 | })
38 | }
39 |
40 | export async function createToastHeaders(toastInput: ToastInput) {
41 | const session = await toastSessionStorage.getSession()
42 | const toast = ToastSchema.parse(toastInput)
43 | session.flash(toastKey, toast)
44 | const cookie = await toastSessionStorage.commitSession(session)
45 | return new Headers({ 'set-cookie': cookie })
46 | }
47 |
48 | export async function getToast(request: Request) {
49 | const session = await toastSessionStorage.getSession(
50 | request.headers.get('cookie'),
51 | )
52 | const result = ToastSchema.safeParse(session.get(toastKey))
53 | const toast = result.success ? result.data : null
54 | return {
55 | toast,
56 | headers: toast
57 | ? new Headers({
58 | 'set-cookie': await toastSessionStorage.destroySession(session),
59 | })
60 | : null,
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/docs/decisions/002-email-service.md:
--------------------------------------------------------------------------------
1 | # Email Service
2 |
3 | Date: 2023-05-08
4 |
5 | Status: superseded by [017](017-resend-email.md)
6 |
7 | ## Context
8 |
9 | When you're building a web application, you almost always need to send emails
10 | for various reasons. Packages like `nodemailer` make it quite easy to send your
11 | own emails through your own mailserver or a third party's SMTP server as well.
12 |
13 | Unfortunately,
14 | [deliverability will suffer if you're not using a service](https://cfenollosa.com/blog/after-self-hosting-my-email-for-twenty-three-years-i-have-thrown-in-the-towel-the-oligopoly-has-won.html).
15 | The TL;DR is you either dedicate your company's complete resources to "play the
16 | game" of email deliverability, or you use a service that does. Otherwise, your
17 | emails won't reliably make it through spam filters (and in some cases it can
18 | just get deleted altogether).
19 |
20 | [The guiding principles](https://github.com/epicweb-dev/epic-stack/blob/main/docs/guiding-principles.md)
21 | discourage services and encourage quick setup.
22 |
23 | ## Decision
24 |
25 | We will use a service for sending email. If emails don't get delivered then it
26 | defeats the whole purpose of sending email.
27 |
28 | We selected [Mailgun](https://www.mailgun.com/) because it has a generous free
29 | tier and has proven itself in production. However, to help with quick setup, we
30 | will allow deploying to production without the Mailgun environment variables set
31 | and will instead log the email to the console so during the experimentation
32 | phase, developers can still read the emails that would have been sent.
33 |
34 | During local development, the Mailgun APIs are mocked and logged in the terminal
35 | as well as saved to the fixtures directory for tests to reference.
36 |
37 | ## Consequences
38 |
39 | Developers will need to either sign up for Mailgun or update the email code to
40 | use another service if they prefer. Emails will actually reach their
41 | destination.
42 |
--------------------------------------------------------------------------------
/docs/decisions/010-memory-swap.md:
--------------------------------------------------------------------------------
1 | # Memory Swap
2 |
3 | Date: 2023-06-02
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | Node.js based apps can use a lot of memory. And while we can scale up the memory
10 | on the instances that run your app, we can't scale it up infinitely. Especially
11 | when we want to be cost sensitive. So we need to be able to handle the case
12 | where your app uses more memory than is available on the instance. A solution to
13 | this is to use swap memory.
14 |
15 | Swap memory is a way to use disk space as memory. It's not as fast as real
16 | memory, but it's better than crashing. And it's a lot cheaper than scaling up
17 | the memory on your instances. It makes sense for many types of apps (even at
18 | scale) to use swap memory. Especially for apps just getting off the ground,
19 | making use of swap memory can be a great way to keep costs down.
20 |
21 | Because our app is running in a container with a mounted volume, we can't use
22 | the normal swap memory mechanisms. Instead, we need to use a swap file. This
23 | means we need to create a file on the mounted volume and then use that file as
24 | swap memory using `fallocate`, `mkswap`, and `swapon`.
25 |
26 | Size of the swap file is pretty subjective to the application and situation. The
27 | Epic Stack app memory starts at 256MB on Fly. Based on that amount of memory, a
28 | good rule of thumb for the size of the swap file is 2-4x the size of memory,
29 | which would put the swap file at 512MB-1GB (for a 2GB+ RAM system, you typically
30 | want the swap file to be the same size as the memory). Considering our volumes
31 | are set to 1GB for starters, we'll start with a 512MB swap file.
32 |
33 | ## Decision
34 |
35 | During app startup, we'll create a swap file on the mounted volume and then use
36 | that file as swap memory for the application.
37 |
38 | ## Consequences
39 |
40 | In high utilization situations, we will have degraded performance instead of a
41 | crash. This is a good tradeoff for most apps.
42 |
--------------------------------------------------------------------------------
/app/components/progress-bar.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react'
2 | import { useNavigation } from 'react-router'
3 | import { useSpinDelay } from 'spin-delay'
4 | import { cn } from '#app/utils/misc.tsx'
5 | import { Icon } from './ui/icon.tsx'
6 |
7 | function EpicProgress() {
8 | const transition = useNavigation()
9 | const busy = transition.state !== 'idle'
10 | const delayedPending = useSpinDelay(busy, {
11 | delay: 600,
12 | minDuration: 400,
13 | })
14 | const ref = useRef(null)
15 | const [animationComplete, setAnimationComplete] = useState(true)
16 |
17 | useEffect(() => {
18 | if (!ref.current) return
19 | if (delayedPending) setAnimationComplete(false)
20 |
21 | const animationPromises = ref.current
22 | .getAnimations()
23 | .map(({ finished }) => finished)
24 |
25 | void Promise.allSettled(animationPromises).then(() => {
26 | if (!delayedPending) setAnimationComplete(true)
27 | })
28 | }, [delayedPending])
29 |
30 | return (
31 |
37 |
49 | {delayedPending && (
50 |
51 |
57 |
58 | )}
59 |
60 | )
61 | }
62 |
63 | export { EpicProgress }
64 |
--------------------------------------------------------------------------------
/docs/decisions/041-image-optimization.md:
--------------------------------------------------------------------------------
1 | # Introduce Image Optimization
2 |
3 | Date: 2025-02-19
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | As documented in [018-images.md](./018-images.md), the Epic Stack previously
10 | didn't implement image optimization. Both static app images and dynamic user
11 | images were served as is. However, optimizing images significantly improves web
12 | performance by reducing both the browser load time and the byte size of each
13 | image. On the other hand, one of the guiding principles of the Epic Stack is to
14 | limit services (including the self-managed variety). A great middle ground is to
15 | integrate a simple image optimization solution directly into the web server.
16 | This allows each Epic Stack app to immediately utilize image optimization and
17 | serve better web experiences without prescribing a service.
18 |
19 | On-demand image optimization with a image optimization endpoint should be
20 | sufficient for most applications and provide value right out of the gate.
21 | However, it is also important that upgrading to a dedicated service shouldn't be
22 | overly complicated and require a ton of changes.
23 |
24 | ### Using openimg
25 |
26 | The goal of openimg is to be easy to use but also highly configurable, so you
27 | can reconfigure it (or replace it) as your app grows. We can start simple by
28 | introducing a new image optimization endpoint and replace `img` elements with
29 | the `Img` component.
30 |
31 | ## Decision
32 |
33 | Introduce an image optimization endpoint using the
34 | [openimg package](https://github.com/andrelandgraf/openimg). We can then use the
35 | `Img` component to query for optimized images and iterate from there.
36 |
37 | ## Consequences
38 |
39 | Serving newly added images will now lead to an image optimization step whenever
40 | a cache miss happens. This increases the image laod time but greatly reduces the
41 | images sizes. On further requests, the load time should also be improved due to
42 | the decreased image sizes.
43 |
--------------------------------------------------------------------------------
/app/routes/admin/cache/sqlite.server.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from 'react-router'
2 | import { z } from 'zod'
3 | import { cache } from '#app/utils/cache.server.ts'
4 | import {
5 | getInstanceInfo,
6 | getInternalInstanceDomain,
7 | } from '#app/utils/litefs.server.ts'
8 | import { type Route } from './+types/sqlite.ts'
9 |
10 | export async function updatePrimaryCacheValue({
11 | key,
12 | cacheValue,
13 | }: {
14 | key: string
15 | cacheValue: any
16 | }) {
17 | const { currentIsPrimary, primaryInstance } = await getInstanceInfo()
18 | if (currentIsPrimary) {
19 | throw new Error(
20 | `updatePrimaryCacheValue should not be called on the primary instance (${primaryInstance})}`,
21 | )
22 | }
23 | const domain = getInternalInstanceDomain(primaryInstance)
24 | const token = process.env.INTERNAL_COMMAND_TOKEN
25 | return fetch(`${domain}/admin/cache/sqlite`, {
26 | method: 'POST',
27 | headers: {
28 | Authorization: `Bearer ${token}`,
29 | 'Content-Type': 'application/json',
30 | },
31 | body: JSON.stringify({ key, cacheValue }),
32 | })
33 | }
34 |
35 | export async function action({ request }: Route.ActionArgs) {
36 | const { currentIsPrimary, primaryInstance } = await getInstanceInfo()
37 | if (!currentIsPrimary) {
38 | throw new Error(
39 | `${request.url} should only be called on the primary instance (${primaryInstance})}`,
40 | )
41 | }
42 | const token = process.env.INTERNAL_COMMAND_TOKEN
43 | const isAuthorized =
44 | request.headers.get('Authorization') === `Bearer ${token}`
45 | if (!isAuthorized) {
46 | // nah, you can't be here...
47 | return redirect('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
48 | }
49 | const { key, cacheValue } = z
50 | .object({ key: z.string(), cacheValue: z.unknown().optional() })
51 | .parse(await request.json())
52 | if (cacheValue === undefined) {
53 | await cache.delete(key)
54 | } else {
55 | // @ts-expect-error - we don't reliably know the type of cacheValue
56 | await cache.set(key, cacheValue)
57 | }
58 | return { success: true }
59 | }
60 |
--------------------------------------------------------------------------------
/docs/seo.md:
--------------------------------------------------------------------------------
1 | # SEO
2 |
3 | Remix has built-in support for setting up `meta` tags on a per-route basis which
4 | you can read about
5 | [in the Remix Metadata docs](https://remix.run/docs/en/main/route/meta).
6 |
7 | The Epic Stack also has built-in support for `/robots.txt` and `/sitemap.xml`
8 | via [resource routes](https://remix.run/docs/en/main/guides/resource-routes)
9 | using [`@nasa-gcn/remix-seo`](https://github.com/nasa-gcn/remix-seo). By
10 | default, all routes are included in the `sitemap.xml` file, but you can
11 | configure which routes are included using the `handle` export in the route. Only
12 | public-facing pages should be included in the `sitemap.xml` file.
13 |
14 | Here are two quick examples of how to customize the sitemap on a per-route basis
15 | from the `@nasa-gcn/remix-seo` docs:
16 |
17 | ```tsx
18 | // routes/blog/_layout.tsx
19 | import { type SEOHandle } from '@nasa-gcn/remix-seo'
20 | import { serverOnly$ } from 'vite-env-only/macros'
21 |
22 | export const handle: SEOHandle = {
23 | getSitemapEntries: serverOnly$(async (request) => {
24 | const blogs = await db.blog.findMany()
25 | return blogs.map((blog) => {
26 | return { route: `/blog/${blog.slug}`, priority: 0.7 }
27 | })
28 | }),
29 | }
30 | ```
31 |
32 | Note the use of
33 | [`vite-env-only/macros`](https://github.com/pcattori/vite-env-only). This is
34 | because `handle` is a route export object that goes in both the client as well
35 | as the server, but our sitemap function should only be run on the server. So we
36 | use `vite-env-only/macros` to make sure the function is removed for the client
37 | build. Support for this is pre-configured in the `vite.config.ts` file.
38 |
39 | ```tsx
40 | // in your routes/url-that-doesnt-need-sitemap
41 | import { type SEOHandle } from '@nasa-gcn/remix-seo'
42 | import { type Route } from './+types/sitemap[.]xml.ts'
43 |
44 | export async function loader({ request }: Route.LoaderArgs) {
45 | /**/
46 | }
47 |
48 | export const handle: SEOHandle = {
49 | getSitemapEntries: () => null,
50 | }
51 | ```
52 |
--------------------------------------------------------------------------------
/app/routes/_marketing/+logos/remix.svg:
--------------------------------------------------------------------------------
1 |
26 |
--------------------------------------------------------------------------------
/docs/timezone.md:
--------------------------------------------------------------------------------
1 | # Timezones
2 |
3 | Server rendering timezones has always been a pain. This is because the server
4 | doesn't know the user's timezone. It only knows the timezone of the server. So
5 | lots of people will take the easy way out and do one of the following
6 | workarounds:
7 |
8 | - Just render in UTC: Not great because it's not the user's timezone
9 | - Render in the server's timezone: Not great because it's not the user's
10 | timezone
11 | - Render in the server's timezone, and hydrate in the client's timezone: Not
12 | great because it causes a flash of incorrect content (and a hydration error
13 | unless you add `suppressHydrationWarning={true}` to the element)
14 | - Don't render the time on the server at all: Not great because it's a flash of
15 | incomplete content (and no, fading it in does not count).
16 | - Only render the time from user interaction: Sometimes this is fine, but often
17 | you're just compromising on UX and you know it.
18 |
19 | Thanks to the Epic Stack's built-in support for
20 | [client hints](./client-hints.md), we can do better! We have a client hint set
21 | up for the user's timezone. This means you can render the time on the server in
22 | the user's timezone, and hydrate it in the user's timezone, without any flash of
23 | incorrect content or hydration errors.
24 |
25 | You can use this in a few ways. In server-side only code,
26 | `getHints(request).timeZone` will be what you're looking for. In UI code, you
27 | can use `useHints().timeZone` to get the user's timezone.
28 |
29 | For the server-side code, we have a `getDateTimeFormat` utility uses to give you
30 | a
31 | [`DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat)
32 | object that is in the user's timezone (it also uses the standard
33 | `accept-language` header to determine the user's preferred locale).
34 |
35 | If you'd prefer to use a library for formatting dates and times, feel free to
36 | simply access the timezone from the hints and use it with your library of
37 | choice.
38 |
--------------------------------------------------------------------------------
/app/components/ui/input-otp.tsx:
--------------------------------------------------------------------------------
1 | import { OTPInput, OTPInputContext } from 'input-otp'
2 | import * as React from 'react'
3 |
4 | import { cn } from '#app/utils/misc.tsx'
5 |
6 | const InputOTP = ({
7 | className,
8 | containerClassName,
9 | ...props
10 | }: React.ComponentProps) => (
11 |
21 | )
22 |
23 | const InputOTPGroup = ({
24 | className,
25 | ...props
26 | }: React.ComponentProps<'div'>) => (
27 |
32 | )
33 |
34 | const InputOTPSlot = ({
35 | index,
36 | className,
37 | ...props
38 | }: React.ComponentProps<'div'> & {
39 | index: number
40 | }) => {
41 | const inputOTPContext = React.useContext(OTPInputContext)
42 | const slot = inputOTPContext.slots[index]
43 | if (!slot) throw new Error('Invalid slot index')
44 | const { char, hasFakeCaret, isActive } = slot
45 |
46 | return (
47 |
58 | ),
59 | idle: null,
60 | }[status]
61 |
62 | return (
63 |
76 | )
77 | }
78 | StatusButton.displayName = 'Button'
79 |
--------------------------------------------------------------------------------
/docs/decisions/023-route-based-dialogs.md:
--------------------------------------------------------------------------------
1 | # Route-based Dialogs (aka Modals)
2 |
3 | Date: 2023-07-14
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | Dialogs (also known as modals) are often a crutch for poor UX design. They are
10 | often used when you haven't thought through the design of the page within the
11 | context of the user's intentions.
12 |
13 | They aren't always bad though. Sometimes they are useful to provide a
14 | confirmation step before a destructive action. For this we already have the
15 | `useDoubleCheck` hook which makes it easier to help the user confirm their
16 | action, but using a dialog gives you the opportunity to explain to the user a
17 | bit more before the action is completed.
18 |
19 | However, using Dialogs for routes is problematic. Dialogs without animations are
20 | poor UX. But server rendering animations is problematic because it means the
21 | user has to wait for the animation code to load before they see the content they
22 | came for.
23 |
24 | Unsplash solves this problem by using dialogs for images when you click on them,
25 | but when you refresh the page you see that image's page. This is an intentional
26 | decision by them and I'm sure they weighed the pros and cons for this UX.
27 | However, it's not often this is a good user experience.
28 |
29 | Until today, the Epic Stack used route-based dialogs for the 2FA flow and the
30 | avatar edit experience. I like using routes for these so it's easy to link the
31 | user directly to these pages and makes it easier to navigate in and out of them.
32 |
33 | These are definitely not a good use of route-based dialogs. It certainly doesn't
34 | make sense to render it as a dialog for a client-navigation but something else
35 | for landing on that page like unsplash does for its images.
36 |
37 | ## Decision
38 |
39 | Remove route-based dialogs from the Epic Stack.
40 |
41 | ## Consequences
42 |
43 | A better UX. What used to be dialogs will now simply be pages. To help with
44 | navigation, we'll need to use breadcrumbs to help the user orient themselves and
45 | find a way back to where they came from.
46 |
--------------------------------------------------------------------------------
/docs/decisions/036-vite.md:
--------------------------------------------------------------------------------
1 | # Adopting Vite
2 |
3 | Date: 2024-02-22
4 |
5 | Status: accepted
6 |
7 | ## Context
8 |
9 | [The Remix Team has created a Vite Plugin](https://remix.run/blog/remix-vite-stable)
10 | and it is now stable. It can be used to replace the existing remix compiler. In
11 | Remix v3 the plugin will be the only supported way to build remix applications.
12 |
13 | Using vite also means we get better hot module replacement, a thriving ecosystem
14 | of tools, and shared efforts with other projects using vite.
15 |
16 | If we don't adopt vite, we'll be stuck on Remix v2 forever 🙃 Now that the vite
17 | plugin is stable, adopting vite is really the only way forward.
18 |
19 | That said, we currently have a few route modules that mix server-only utilities
20 | with server/client code. In vite, you cannot have any exported functions which
21 | use server-only code, so those utilities will need to be moved. Luckily, the
22 | vite plugin will fail the build if it finds any issues so if it builds, it
23 | works. Additionally, this will help us make a cleaner separation between server
24 | and server/client code which is a good thing.
25 |
26 | The simple rule is this: if it's a Remix export (like `loader`, or `action`)
27 | then it can be in the route. If it's our own utility export (like
28 | `requireRecentVerification` we had in the `/verify` route) then it needs to go
29 | in a `.server` file. To be clear, if you don't export it, then it's fine.
30 | Server-only utility functions are fine in routes. It just becomes a problem for
31 | remix when they are exported.
32 |
33 | An interesting exception to this is sever-only code in the `handle` export like
34 | the [`getSitemapEntries` function](https://github.com/nasa-gcn/remix-seo). For
35 | this, you need to use
36 | [`vite-env-only`](https://github.com/pcattori/vite-env-only).
37 |
38 | ## Decision
39 |
40 | Adopt vite.
41 |
42 | ## Consequences
43 |
44 | Almost everything is better. We have slightly more complicated rules around the
45 | server/client code separation, but for the most part that's better and there are
46 | fewer surprises.
47 |
--------------------------------------------------------------------------------