Warning This stack is typescript and NPM only for now.
The NPM requirement comes from the GitHub actions scripts. I will make it possible to use both pnpm and yarn soon, but it requires a bit more time and I would love to get feedback on the stack until then.
This is a Remix stack that offers a way to ship production ready remix applications. It is constructed in an opinionated way and is meant to be used as a starting point for your own remix projects. You can modify it to your liking and use it as a base for your own remix projects.
Create your project with the stack
npx create-remix@latest --template meza/trance-stack my-app
The setup process will ask you for a GitHub repository name. If you don't have one, don't worry, you can create it after the setup process.
Warning
Read this documentation in your own project's directory from this point onwards. It will contain links relevant to you as the init script will replace the links in this README with the ones customized to your project.
Now start the dev server
npm run dev
This has set you up with a default remix application. It won't function well until you complete the setup process. You can find the instructions for that here
npm install
npm run dev
npm run ci
- run the same verification scripts that are run on CInpm run clean
- removes all generated filesnpm run clean:all
- removes all generated files and all the node_modules directoriesnpm run dev
- Starts the dev servernpm run deploy:dev
- Deploys the application to an ephemeral environmentnpm run deploy:prod
- Deploys the application to production (you probably should never use this one locally)npm run int
- Runs the Playwright integration testsnpm run report
- Runs all the things that generate reports for you (coverage, cpd, loc, etc)npm run storybook
- Starts the storybook servernpm run validate
- Runs both the CI tests and the integration testsfeatures.ts
fileIn order to get this project to work, you will need to have a few things set up first.
The stack is designed in a way that makes it relatively simple to remove the parts you don't need. You will be able to find removal instructions at every step so don't worry if you're not a fan of a particular service.
But... why?
Note We've been using Architecture Decision Records throughout the development of the project so if you ever find yourself wondering why we've chosen a particular service or implementation, you can check the ADR page for more information.
We highly encourage you to keep on adding your own decisions. It's a great way to document the historical context of your project, and it's a great way to share your knowledge with the rest of the team.
We use adr-tools to manage our ADRs. It is installed as part of the dependencies, so you should be able to use it right away.
Check the project root directory for a .env
file. If it's not there, copy the .env.example
file to .env
cp .env.example .env
This file contains all the variables you will need to set for the project to function as is.
The APP_DOMAIN
should generally stay the same. It's the domain that your application will be served from. This
variable
will also be set by the deployment scripts, so you don't need to worry about it. During local development
it will be set to http://localhost:3000
.
The NODE_ENV
variable is used to determine which environment you're running the application in. It seems like ARC has
a
hard time figuring it out on its own, so we've set it up to be set manually. If all goes well, it won't be needed for
long.
The SESSION_SECRET
variable is used to encrypt the session cookies. It should be a long, random string.
Note The project uses GitHub Actions. If you're not familiar with GitHub Actions, you can read more about it here.
You need to do a few things to make sure GitHub Actions can work with your project.
First, head over to https://github.com/meza/trance-stack/settings/actions and under the Workflow permissions
section, make sure it's on the Read and write permissions
option.
Without this, the deployment scripts won't be able to create the necessary GitHub releases.
Next, head over to https://github.com/meza/trance-stack/settings/branches and add a few branch protection rules.
These are the branches that will be used for the different stages of the application. You can set the settings of these
branches however you like, but there's one setting that you need to make sure is unchecked: Allow deletions
.
We use this later in the Deployment section to prevent named environments from being deleted.
Next, head over to https://github.com/meza/trance-stack/settings/pages and make sure the Source
is set
to GitHub Actions
.
This will allow us to deploy the project's storybook to GitHub Pages.
Note We use GitHub environments to manage the different stages of our application. You can read more about them here.
GitHub environments are great to control the environment variables that are used in your workflows.
For now, go to https://github.com/meza/trance-stack/settings/environments and create the following environments:
Production
Staging
Ephemeral
These are referred to in the deployment workflow for example with the environment
key.
The Ephemeral
environment is used for feature branches and pull requests and is referenced
in the ephemeral workflow.
Some configuration values are sensitive while others are not. For example, the COOKIEYES_TOKEN
is not sensitive, but
the
AUTH0_CLIENT_SECRET
is.
This mainly comes from the fact that some of these values will be embedded into the html of your application and be
visible
to everyone.
Warning Please double-check the documentation of the services to ensure you're setting them up correctly.
The application won't work properly if you add a secret as a variable or a variable as a secret.
For the releases to work properly, you will need to create a Personal Access Token. It needs the following settings:
Once you've created the token, go to the secrets settings and add it as GH_TOKEN
The deployment processes are described in the Deployment section but to get you started, please create the environment variables and secrets defined in the environment variables section.
We use Auth0 for authentication. You will need to create an account with them and set up an application.
When creating your new application, make sure to set the following settings:
Regular Web Applications
Domain
and Client ID
and Client Secret
and paste them in the .env
filePost
Allowed Callback URLs
section and add http://localhost:3000/auth/callback
Allowed Logout URLs
section and add http://localhost:3000
Allowed Web Origins
section and add http://localhost:3000
Allowed Origins (CORS)
section and add http://localhost:3000
Refresh Token Rotation
section and enable it and with that, you also have to enable
the Absolute Expiration
option.Now that you have your Auth0 variables, you will need to add them to the GitHub environments you created above.
Go to the secrets settings and add the Auth0 secrets with the same name as the
variables in the .env
file.
You can set custom values for every environment if you want to. For example, you can set the AUTH0_DOMAIN
to
dev-123456.eu.auth0.com
for the Staging
environment and prod-123456.eu.auth0.com
for the Production
environment.
But for the sake of simplicity, you can just set the same values only once in the main Actions secrets page, and it will be used for all environments.
If you want to enable the Auth0 integration for feature branch/PR deployments, you will need to do a few extra steps.
Since the feature branch/PR deployments are ephemeral, they will have a different domain name every time they are
deployed. This means that you will need to add the domain name to the Allowed Callback URLs
and Allowed Logout URLs
To make this painless, we can use the *
wildcard in the domain name. This will allow any domain name to be used.
During the initial setup above, you have added http://localhost:3000
in a few places.
You will need to add ,https://*.execute-api.us-east-1.amazonaws.com
to the same places.
(Note the comma at the beginning. Domains need to be separated by commas)
Note You will need to replace the
us-east-1
part with the region you're using.
For example, the Allowed Callback URLs section should look like this:
http://localhost:3000/auth/callback,https://*.execute-api.us-east-1.amazonaws.com/auth/callback
Warning
The
*
wildcard will allow you to use as wide of a domain name as you would like to. This however comes at the cost of security. We would highly recommend creating an alternative tenant on Auth0 for your feature branch/PR deployments.
AUTH0_DOMAIN
, AUTH0_CLIENT_ID
and AUTH0_CLIENT_SECRET
variables from the .env
file and GitHub
secrets.src/auth.server.ts
and the src/auth.server.test.ts
files.auth0-remix-server
dependency from the package.json
file.auth0-remix-server
dependency.We use Google Analytics v4 for analytics. You will need to create an account with them and set up a property.
When you are done setting up your property, you will need to copy the Measurement ID
of your Data Stream and paste
set the GOOGLE_ANALYTICS_ID
variable in the .env
file.
You will also have to go to the variables settings and add the same variable
name as the one in the .env
file.
Warning The
GOOGLE_ANALYTICS_ID
is set as a variable for the actions.
GOOGLE_ANALYTICS_ID
variable from the .env
file and GitHub variables.src/components/GoogleAnalytics
directory.appConfig
type in the src/types/global.d.ts
file.<GoogleAnalytics ... />
component and its import from the src/root.tsx
file.vitest --run --update
to update the snapshots.We use Hotjar for heatmaps and user recordings. You will need to create an account with them and set up a new site.
When you have your site set up, head to https://insights.hotjar.com/site/list and copy the ID of your site and paste
set the HOTJAR_ID
variable in the .env
file.
You will also have to go to the variables settings and add the same variable
name as the one in the .env
file.
Warning The
HOTJAR_ID
is set as a variable for the actions.
HOTJAR_ID
variable from the .env
file and GitHub variables.src/components/Hotjar
directory.appConfig
type in the src/types/global.d.ts
file.<Hotjar ... />
component and its import from the src/root.tsx
file.vitest --run --update
to update the snapshots.We use PostHog for analytics. You will need to create an account with them and set up a new project.
When you have your project set up, head to https://posthog.com/project/settings and copy the API key of your project and paste
set the POSTHOG_TOKEN
variable in the .env
file.
You also need to set the POSTHOG_API
variable to either https://eu.posthog.com
or https://posthog.com
depending on your
data residency preferences.
You will also have to go to the variables settings and add the same variable names as the one in the .env
file.
In PostHog, your main unit is called an Organization. An organization can have multiple "projects" which are
essentially environments. For example, you can have a production
project and a staging
project.
This allows you to have different feature flags, users and data for each environment. Feel free to create a new project for each environment and then set the appropriate environment variables.
POSTHOG_TOKEN
and POSTHOG_API
variables from the .env
file and GitHub variables.src/components/Posthog
directory.appConfig
type in the src/types/global.d.ts
file.<Posthog ... />
component and its import from the src/root.tsx
file.vitest --run --update
to update the snapshots.posthog
dependency from the package.json
file.posthog
dependency.We use Renovate to manage dependency updates. To take advantage of it, you will need to install the Renovate GitHub App.
First, navigate to https://github.com/apps/renovate and click on the Install button.
On the following screen, we recommend selecting "All repositories" to make life easier, but you can configure it to only work on the repository you're currently in.
Note Due to compatibility issues with Architect, the server-side instrumentation of Sentry is not working for now. Keep an eye on this issue for updates. The relevant code is commented out in the
entry.server.tsx
file.
We use Sentry for error reporting. You will need to create an account with them and set up a new project.
When you have your project set up, head to the project settings and copy the DSN
and paste it
set the SENTRY_DSN
variable in the .env
file.
You will also have to go to the variables settings and add the same variable
name as the one in the .env
file.
Next, head over to https://sentry.io/settings/account/api/auth-tokens/ and create a new token.
You will need project:releases
and project:read
permissions.
Once you have the token, go to the secrets settings and add
SENTRY_AUTH_TOKEN
- the token you just createdSENTRY_ORG
- the organization slugSENTRY_PROJECT
- the project slugWe will be using these to send the source maps to Sentry so that the errors are properly mapped to the source code.
The deployment script will automatically upload the source maps to Sentry and then remove them locally, so they don't get uploaded to the environments.
First, Go to the project settings
Then on the sidebar, click on the Client Keys (DSN)
Finally, copy the DSN
value
SENTRY_DSN
variable from the .env
file and GitHub variables.npm remove @sentry/*
to remove all the sentry packages.sentryDsn
from the appConfig
and the SENTRY_DSN
from the ProcessEnv
type in
the src/types/global.d.ts
file.src/root.tsx
file, replace the withSentry(App)
with App
.Sentry.init
call from the src/entry.client.tsx
and the src/entry.server.tsx
files..github/workflows/deploy.yml
and the .github/workflows/ephemeralDeply.yml
files and remove
the Sentry Sourcemaps
step.This section dives deeper into the concepts present in the stack.
The authentication is done via the auth0-remix-server package. The README file in that package has all the information you need to understand how it works.
We use Conventional Commits to automatically determine the next version of the package. It uses the semantic-release package to automate the versioning and release process.
The functionality is controlled by the .releaserc.json
file.
Since the projects that are created from this stack are most likely aren't going to be npm libraries, the npm publishing
plugin is not included in the configuration.
To effectively use conventional commits, you need to understand the following basic principle:
Your commit messages determine if a new deployment happens to production.
Messages that trigger builds are:
fix: ...
- fixes a bugfeat: ...
- adds a new featureMessages that don't trigger new versions (therefore builds) are:
docs: ...
- changes to the documentationchore: ...
- changes to the build process or auxiliary tools and libraries such as documentation generationrefactor: ...
- code changes that neither fixes a bug nor adds a featurestyle: ...
- changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)test: ...
- adding missing tests or correcting existing testsci: ...
- changes to the CI configuration files and scriptsperf: ...
- a code change that improves performanceWe will talk about how the deployment works in the Deployment section. For now, let's look at how the branching strategy works with the versioning.
There are 3 main branches:
main
- this is the main branch. It is the branch that is deployed to production.beta
- this is the branch that is deployed to the beta (Staging) environment.alpha
- this is the branch that is deployed to the alpha (Staging) environment.When you push to the main
branch, a new version is released to production. The version is determined by the commit
messages
and every commit that is pushed to the main
branch will trigger a new version.
When you push to the alpha
or beta
branch, a
new Pre-release
version is created. This allows you to iterate on features for an upcoming release and not worry about bumping the
version
number every time you push a commit that introduces a new feature or a fix.
For example, if you have a 1.0.0
version in production, and you push a commit to the alpha
branch, the version will
be
1.1.0-alpha.0
. If you push another commit to the alpha
branch, the version will be 1.1.0-alpha.1
and so on.
When you merge a pull request from the alpha
or beta
branch to the main
branch, all the changes in those branches
will be collected and bundled into a single release. To follow the example above, if you have a 1.0.0
version in
production,
and merge the alpha
branch with its 1.1.0-alpha.1
version, your newly created version on production will be 1.1.0
.
---
title: Branching & Versioning
---
%%{title: '', init: {'theme': 'base', 'gitGraph': {'rotateCommitLabel': true}} }%%
gitGraph
commit id: "v1.0.0"
branch feature order: 2
branch alpha order: 1
checkout feature
commit id: "fix: x"
commit id: "fix: y"
checkout alpha
merge feature id: "v1.0.1-alpha.1"
checkout feature
commit id: "fix: z"
checkout alpha
merge feature id: "v1.0.1-alpha.2"
checkout feature
commit id: "feat: added something cool"
commit id: "fix: fixed a mistake"
commit id: "refactor: refactored the tests"
checkout alpha
merge feature id: "v1.1.0-alpha.1"
checkout main
merge alpha id: "v1.1.0"
We use commitlint to lint the commit messages. The configuration is in the
package.json
file.
The linting happens whenever you make a commit. If the commit message doesn't follow the conventional commits format,
the commit will fail.
The linting itself is triggered by lefthook
The version of the app is sent into the <html data-version="...">
attribute. You can use this to determine which
version
of the app is running on any given environment.
We have built a custom cookie consent solution that is compatible with secure XSS protection practices as well as with the EU cookie law.
Note you can read more about this in the Cookie Consent ADR
The solution is in the src/components/CookieConsent
folder, and it is meant to be modified to fit your needs.
When you open up the _index.tsx
file there, you can see the following interfaces:
interface ConsentData {
analytics?: boolean | undefined;
//add your own if you need more
// marketing?: boolean | undefined;
// tracking?: boolean | undefined;
}
interface CookieConsentContextProps {
analytics?: boolean | undefined;
setAnalytics: (enabled: boolean) => void;
//add your own if you need more
// marketing?: boolean | undefined;
// setMarketing: (enabled: boolean) => void;
// tracking?: boolean | undefined;
// setTracking: (enabled: boolean) => void;
}
You will need to modify these in order to add your specific cookie types. For example, if you want to add a marketing
cookie, you will need to add the following:
interface ConsentData {
analytics?: boolean | undefined;
marketing?: boolean | undefined;
}
interface CookieConsentContextProps {
analytics?: boolean | undefined;
setAnalytics: (enabled: boolean) => void;
marketing?: boolean | undefined;
setMarketing: (enabled: boolean) => void;
}
In order to adhere to the cookie consent, you will need to identify the elements of your project that add a specific type of cookie.
A good example in this stack is the GoogleAnalytics component. It is located in the
src/components/GoogleAnalytics
The cookie consent provider is use in the root.tsx
file, so it's available for all your components.
To use it, all you need to do is:
const { analytics } = useContext(CookieConsentContext);
if (analytics) {
//add your analytics code here
}
We use Renovate to automatically update the dependencies.
The configuration is in the .github/renovate.json
file.
By default, it is configured to update the dependencies according to some basic rules:
Runtime dependencies are the
dependencies
section in thepackage.json
file
Runtime dependencies are the libraries we use to run the application. This also means that security and bug fixes are important for these dependencies.
We want to update these dependencies as soon as possible, so we have the following configuration:
minor and patch versions
- create a pull request with a fix:
prefix in the commit message and merge automatically
if possiblemajor versions
- create a pull request with a fix:
prefix in the commit message and do NOT merge automaticallyDevelopment dependencies are the
devDependencies
section in thepackage.json
file
Development dependencies are the libraries we use to develop the application. This means that we don't need to release a new version of the app when we update these dependencies.
We still want to update these dependencies as soon as possible, so we have the following configuration:
minor and patch versions
- create a pull request with a chore:
prefix in the commit message and merge
automatically if possiblemajor versions
- create a pull request with a chore:
prefix in the commit message and do NOT merge automaticallyOne of the main focuses of this stack was to create a deployment strategy that is a good starting point for anyone building from this stack.
We use a combination of GitHub Actions and AWS CDK to deploy the application to both the production-like and ephemeral environments.
Ephemeral environments are environments that are created on-demand and destroyed when they are no longer needed. We use these for feature branches and pull requests.
They are automatically created for pull requests, but you will have to manually trigger one if you just want to deploy a feature branch.
Navigate to https://github.com/meza/trance-stack/actions/workflows/ephemeralDeploy.yml and click the "Run workflow" button.
Once you have chosen a branch, it will start building the application and deploying it to the ephemeral environment.
When the process is finished, it will publish a summary to the run's Summary Dashboard with the link to the deployed application. It will look something like this:
When you create a pull request, GitHub Actions will automatically create an ephemeral environment for you and the deployment link will be added to the pull request as a comment.
Production-like environments are environments that are created once and then updated when the application is updated.
The branch main
is considered to be the production branch while alpha
and beta
are considered to be staging.
This is decided in the deploy.yml
file:
build:
environment: ${{ github.ref_name == 'main' && 'Production' || 'Staging' }}
The Production
and Staging
words here directly reference the GitHub Environments that we have
configured.
Warning This means that both the
alpha
andbeta
branches will be deployed to theStaging
environment.This was done for convenience with the stack, but you are highly encouraged to change this to suit your needs. Maybe add a separate
alpha
environment?Note Remember that the GitHub Environments hold the environment variables used for that given workflow. This means that you can set a different
APP_URL
for each environment among other things like a separate Auth0 tenant.
GitHub Actions respond to various events in the repository's lifecycle. The diagram below shows the flow of the deployment process.
flowchart TD
F1 -.->|Manual Trigger| F
subgraph Push
A[Push] --> D{Is Protected Branch?}
D -->|Yes| H{Is it the 'main' branch?}
D -->|No| F1[Offer Manual Ephemeral Deployment]
H -->|Yes| I1{{Deploy to Production}}
H -->|No| I2{{Deploy to Staging}}
I1 --> J1[Create GitHub Release]
H -->|Yes| J2[Deploy Storybook]
I2 --> J1
end
subgraph Pull Request
B[Pull Request] --> F{{Ephemeral Deployment}}
end
subgraph Cleanup
C[Delete Branch] --> X{{Destroy Deployment Stack}}
end
The hexagonal nodes are processes which are executed by CDK while the others are handled with GitHub Actions.
AWS Cloud Development Kit (CDK) is an open-source software development framework to define cloud infrastructure in code and provision it through AWS CloudFormation.
Note If you are interested in why we chose CDK, check out the relevant ADR
The majority of the infrastructure is defined in the deployment
directory. The deployment/lib
directory contains the
custom Constructs that are used to build the infrastructure.
In order to deploy the application, you will need to set the following environment variables:
Variable | Secret | Description |
---|---|---|
AWS_ACCESS_KEY_ID | The AWS access key ID used to deploy the application. | |
AWS_CERT_ARN | The ARN of the certificate used for the domain. | |
AWS_SECRET_ACCESS_KEY | The AWS secret access key used to deploy the application. | |
AWS_DOMAIN_NAME | The final domain name of the application. | |
AWS_HOSTED_ZONE_NAME | The name of the hosted zone in Route53. |
If you came here from the top of the document, go back to where you were and continue from there.
If you want to deploy the application locally, you will need to set the AWS_ACCESS_KEY_ID
and AWS_SECRET_ACCESS_KEY
environment variables only.
The deployment/stacks
directory contains the actual stacks that are deployed to AWS. Their naming should be
self-explanatory.
We have one for the Ephemeral
environment and one for the Production
environment.
If you examine either of the deployment files, you will notice that the deployment is basically a single command:
npx cdk deploy remix-trance-stack-ephemeral -O /tmp/deployment.result.json \
--require-approval never \
--context environmentName=${{ env.REF_NAME }} \
--context domainName=${{ vars.AWS_DOMAIN_NAME }} \
--context certificateArn=${{ secrets.AWS_CERT_ARN }} \
--context hostedZoneName=${{ vars.AWS_HOSTED_ZONE_NAME }}
The difference between the ephemeral and the production deployments is the name of the stack.
It can be either remix-trance-stack-ephemeral
or remix-trance-stack-production
.
The context variables are used to pass information to the CDK stack.
Variable | Description | Example |
---|---|---|
environmentName |
The name of the environment. This is used to create derive the name of every single resource created on AWS. | feature1 |
domainName |
The domain name of the application. | trance-stack.vsbmeza.com |
certificateArn |
The ARN of the certificate used for the application. | arn:aws:acm:region:123456789012:certificate/12345678-1234-1234-1234-123456789012 |
hostedZoneName |
The name of the hosted zone used for the application. | vsbmeza.com |
The
domainName
,certificateArn
andhostedZoneName
are only used for Production deployments.Note Even though some context variables are only used for production deployments, they are still passed to the ephemeral deployment. This is because the CDK stack is the same for both environments and the evaluation of the context variables is done at runtime. For ephemeral deployments you can have an empty string for the
domainName
,certificateArn
andhostedZoneName
.
We advise you to use the GitHub Actions to deploy the application. However, if you want to deploy from your local machine, you can do so by running the same command as the deployment scripts would.
Warning Don't forget to run
npm run build
before deploying.
You can define the context variables either on the command line or you can use the cdk.context.json
file.
{
"environmentName": "localdev",
"domainName": "trance-stack.example.com",
"hostedZoneName": "example.com",
"certificateArn": "arn:aws:acm:region:123456789012:certificate/12345678-1234-1234-1234-123456789012"
}
Let's talk about the githubActionSupport.ts
file.
This file uses the GitHub Actions toolkit to allow us to report back with the deployment URL to the GitHub Actions/Pull Request.
The reason it's a bit more complex than it needs to be is because we don't want to publish a PR comment every time we deploy the same branch. Since the URL won't change for a branch that's already deployed, there is no need to spam the PR.
This posed a challenge of finding an existing deployment comment and updating it instead of creating a new one.
If for whatever reason you would like to get a local output of the GitHub Actions support, you can do so by running the following command:
npx ts-node --prefer-ts-exts deployment/githubActionSupport.ts /tmp/deployment.result.json
This requires you to have a deployment.result.json
file in the /tmp
directory. You can get this file by running the
deployment command locally.
The results will be added to the deploymentSummary.md
file.
Environment variables are probably the biggest pain-point in the maintenance of this project.
You have to add them to GitHub, add them to the deploy scripts and add them to the .env
file.
We're working on a solution to this, but for now, you have to do it manually.
Add the variable to...
.env
file.env.example
script. This is very important.github/workflows/deploy.yml
script to the npm run build
command.github/workflows/ephemeralDeploy.yml
script to the npm run build
command.github/workflows/ephemeralDestroy.yml
script to the npm run build
command.github/workflows/playwright.yml
script to the Create Envfile
sectionWe bundle most of the environment variables into the server bundle. To understand why, read the relevant adr, and it's addendum.
The important thing to know is that what gets bundled is decided by reading the .env.example
file and taking its
keys.
You can prevent certain keys to get bundled by adding them to the deny list in the remix.config.js
file.
const doNotBundleEnv = [
'APP_DOMAIN' // deny list for the environmentPlugin
]
Feature flags are a fantastic way to test new features in production without having to worry about breaking anything. It enables you to decouple the release of new code from the release of new features. Read more
Let's look at an example which is in the src/routes/_index.tsx
file
export const loader: LoaderFunction = async ({ request, context }) => {
const isAuth = await hasFeature(request, Features.AUTH);
return json({
isHelloEnabled: await hasFeature(request, Features.HELLO),
isAuthEnabled: isAuth
});
};
export default () => {
const { isHelloEnabled, isAuthEnabled } = useLoaderData<typeof loader>();
if (isHelloEnabled) {
return (<div>
<Hello/>
{isAuthEnabled ? <Login/> : null}
</div>);
}
return <div>Goodbye World!</div>;
};
Here all elements of the page are wrapped in a feature flag. The Hello
component will only be rendered if the HELLO
feature is enabled. The Login
component will only be rendered if the AUTH
feature is enabled.
In PostHog, your main unit is called an Organization. An organization can have multiple "projects" which are
essentially environments. For example, you can have a production
project and a staging
project.
This allows you to have different feature flags, users and data for each environment. Feel free to create a new project for each environment and then set the appropriate environment variables.
We're using i18next for internationalization. You can read more about it in the i18next documentation. To integrate it with Remix, we're using the remix-i18next package and our setup is based on the remix-i18next Readme file.
You can find the i18n configuration in the src/i18n
directory. The i18n.config.ts
file contains the configuration
for
the defaults of i18next. The i18n.server.ts
file contains the configuration for the server side while
the i18n.client.ts
file contains the configuration for the client side.
The only deviation we have from the remix-i18next sample setup is that we're actually bundling the translations into the
server package. This is done in the src/i18n/i18n.server.ts
file.
await i18nextInstance.init({
debug: process.env.I18N_DEBUG === 'true',
...baseConfig,
lng: locale,
ns: remixI18next.getRouteNamespaces(remixContext),
// The sample setup in remix-i18next
//backend: {
// loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"),
//},
resources: {
en: {
translation: en
}
}
});
We're doing this because in the AWS Lambda environment, we have one single file as the handler, and it needs to be self-contained. While traditional lambda functions could have access to attached file systems, it would make deployments more complicated and the function would become incompatible with Lambda@Edge solutions.
Therefore, instead of using the fs-backend
, we're directly importing the resources from the public/locales
directory.
This does mean that when you add a new locale, you will have to add it to the resources in the i18n.server.ts
file.
To use translations in your application, you can use the useTranslation
hook from the react-i18next
package.
import { useTranslation } from 'react-i18next';
export const Hello = () => {
const { t } = useTranslation();
return (
<h1 data-testid={'greeting'} className={'hello'}>{t('microcopy.helloWorld')}</h1>
);
};
You can also pass in variables to the translations. This helps the translators to create more context-sensitive translations.
Take this example from the initial logged in Dashboard of the application:
export default () => {
const { t } = useTranslation();
const { user } = useLoaderData<typeof loader>();
return (<>
<div>{t('dashboard.for', { name: user.nickname || user.givenName || user.name })}<br/><Logout/></div>
</>);
};
Here we pass in the name
variable to the translation. This means that the location of where the name appears in the
final
text can be different in different languages. For example, in one context we could say "Dashboard for John!" and in
another context
we could say "John's dashboard!".
The translation file in our dashboard's case looks like this:
{
"dashboard": {
"for": "Dashboard for {{ name }}"
}
}
To add a new locale, you will have to do the following:
public/locales
folder. Follow the example of the existing locale(s)resources
object in the i18n.server.ts
file.supportedLngs
array in the i18n.config.ts
file.If you don't want to use i18n, you can remove it from your project. You will have to do the following:
i18n
folder from the src
directorylocales
folder from the public
directorynpm remove i18next i18next* *i18next
<<I18nextProvider ...>
from both the src/entry.server.tsx
and src/entry.client.tsx
filesNote
There are some great tips about organising your translations in the i18n Readme file.
The commit validation and the automatic dependency installation is done by Lefthook
The configuration file is at .lefthook.yml
.
You can see all the commands that happen and the git hooks they are attached to.
If running all the tests at every commit is too much, you can always set it to happen on pre-push instead.
In case you would ever want to publish your project to NPM (which you shouldn't), you can use
the npmignore package to
automatically generate an .npmignore
file. This file will be generated based on the .gitignore
file.
There is a basic ignore configuration in the package.json
file's publishConfig
section.
We use Playwright for our end-to-end tests. Playwright is a successor to Cypress and Puppeteer. It's maintained by Microsoft and is a cross-browser testing tool. It's also a lot faster than Cypress.
Learn more about Playwright here.
Playwright requires a few dependencies to be installed in order to run locally. You can install them by running the following command:
npx playwright install --with-deps
The tests are located in the playwright/e2e
directory. Feel free to change the directory structure to your liking.
If you do so, don't forget to update the test location in the playwright.config.ts
file.
export default defineConfig({
testDir: './playwright/e2e', // <-- Update this
You do not need to start the dev server before running the tests.
Playwright will start the dev server for you. It is configured in the playwright.config.ts
file right
at the bottom:
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url
:
'http://localhost:3000',
timeout
:
1 * 60 * 1000,
reuseExistingServer
:
!process.env.CI
}
Every time you open a pull request to the main
branch, the tests will be run on GitHub Actions.
You can run the tests locally by running the following command:
npm run int
The reports will go to the reports/e2e
directory.
We use Storybook V7 with Webpack 5. Remix is still a bit behind in terms of Storybook support, so we had to do a few things to get it to work.
Warning Storybook 7 brings some fundamental changes to how Storybook works. It is HIGHLY encouraged that you read the migration guide to see what changed. Things that you are used to might not work the same way anymore.
There is an ongoing discussion within the Remix community about how to best solve this issue.
This code doesn't include the remixStub yet, but it might change soon.
If you know how to configure it properly, please open a PR.
You can run Storybook by running the following command:
npm run storybook
If you're looking for inspiration on how to organise your stories, you can check out the Telekom Scale project
Remember when we set up Pages at the beginning?
Storybook automatically gets published to GitHub Pages when you push to the main
branch.
This is done via the .github/workflows/storybook.yml
workflow.
Right at the top of this README, you can see a badge linking to the published Storybook.
We use regular stylesheets in this project which means a combination of Shared Component Styles and Surfacing Styling.
The shared component styles live in the src/styles
directory. They are imported in the routes that use them.
// src/root.tsx
import styles from './styles/app.css';
export const links: LinksFunction = () => {
return [
{ rel: 'stylesheet', href: styles }
];
};
The styles that are uniform across the entire application are loaded from the src/root.tsx
file while the styles that
are
specific to a single route are loaded from the route itself.
These are all additive, so you can have a single stylesheet that is loaded on every route via the root.tsx
, and then
additional stylesheets that are loaded on specific routes.
If you need a component-specific stylesheet, you can use the Surfacing Styling method.
To have local styles per component, we use is Surfacing Styling.
Because these are not routes, and therefore not associated with a URL segment, Remix doesn't know when to prefetch, load, or unload the styles. We need to "surface" the links up to the routes that use the components
This solution is a bit more complex, but it allows us to have styles that are only loaded when the component is loaded.
Take the Hello
component as an example:
import { useTranslation } from 'react-i18next';
import styles from './hello.css';
export const links = () => [
{ rel: 'stylesheet', href: styles }
];
export const Hello = () => {
const { t } = useTranslation();
return (
<h1 data-testid={'greeting'} className={'hello'}>{t('microcopy.helloWorld')}</h1>
);
};
export default Hello;
Notice that it imports the hello.css
file. This file is located in the same directory as the component.
It also has a links
export that returns the stylesheet link.
In Remix terms however, a component is not a route, so we need to "surface" the links up to the routes that use the
components.
You can see an example of this in the src/routes/_index.tsx
file:
import { Hello, links as helloLinks } from '~/components/Hello';
export const links: LinksFunction = () => ([
...helloLinks()
]);
We import the links
export from the Hello
component and add it to the links
export of the _index.tsx
route.
Yes, this is more complicated than it should be but with the rapid development of Remix, we hope that this will be simplified in the future.
We use PostCSS to process CSS. Remix has a built-in PostCSS plugin that allows you to import CSS files directly into your components. Read more about how CSS in Remix works.
Our PostCSS configuration is located in the postcss.config.js
file, and it gets applied every single time Remix builds
the application.
This means that you don't have to think about prefixes or other browser-specific CSS features. Just write your CSS and
PostCSS will take care of the rest automagically.
We use Typescript Paths. This means that instead of messy relative paths in the imports, we can use handy aliases.
We have the following paths defined by default:
~
- the src
folder@styles
- the src/styles
folder@test
- the test
folderThis means that no matter where you are in the file tree, you can always reference the src
folder with the ~
alias.
import Hello from '~/components/Hello';
import appStyles from '@styles/app.css';
import { renderWithi18n } from '@test';
Feel free to add your own paths in the tsconfig.json
file.
Common ones that you might want to add are:
@components
- the src/components
folder@routes
- the src/routes
folder@hooks
- the src/hooks
folderWe have chosen not to add those because ~/hooks
and @hooks
are not that different to warrant extra settings.
Unfortunately, typescript paths are somewhat esoteric and support across tools can be spotty.
Vitest for example needs special configuration to handle it. You can find the configuration in the vitest.config.ts
file.
It both requires the vite-tsconfig-paths plugin and in some cases
you need to manually add the path to the resolve.alias
array.
// vite.config.ts
resolve: {
alias: {
'~'
:
path.resolve(__dirname, './src')
}
}
Storybook also needs to be told to respect the typescript paths. We use the tsconfig-paths-webpack-plugin to tell the storybook webpack config to respect the paths.
We add it to the webpackFinal
function in the .storybook/main.ts
file.
webpackFinal: async config => {
config.plugins?.push(new DefinePlugin({
__DEV__: process.env.NODE_ENV !== 'production'
}));
if (config.resolve) {
config.resolve.plugins = config.resolve.plugins || [];
config.resolve.plugins.push(new TsconfigPathsPlugin()); // <--- this line
}
return config;
}
We use Vitest as the unit testing framework. If you're unfamiliar with Vitest, fear not, its interface is very similar to Jest and you will have no issues getting started.
The main configuration file of Vitest is located at vitest.config.ts
.
There has been quite a few deliberate decisions made here, so let's go through them.
The globals are off by default but to get js-dom
to work with vitest, they need to be on.
We use different reporters depending on the environment. In the CI environment, we output junit
and cobertura
reports
which then get published to the GitHub Actions Summary or as a Pull Request comment.
On your local machine, we use the html
reporter for coverage and a default text reporter for the test results.
In both cases we also print out a textual representation of the coverage report.
All the test reporting goes into the reports
directory.
If you look closely, you can see that we have a setupFiles
section which calls the
vitest.setup.ts
file. This file is responsible for setting up the environment for the tests.
It installs the @testing-library/jest-dom
package and sets up a universal afterEach
hook to clean up after the
tests.
This might not be to everyone's liking so feel free to change it. Just remember that if you remove the global
afterEach
hook, you will need to clean up after the tests yourself so make sure to run npm run ci
and see what
broke.
Since Remix relies on browser APIs such as fetch that are not natively available in Node.js you may find that your unit tests fail without these globals when running with some tools.
If you need to add more globals, you can do so in the vitest.setup.ts
file.
Simply add:
import { installGlobals } from '@remix-run/node';
// This installs globals such as "fetch", "Response", "Request" and "Headers".
installGlobals();
Read about this more here;
While the promise of threads might sound appealing, switching them on drastically reduces the speed of vitest. This is a known issue, and we're waiting for it to be fixed.
The stack comes with 100%+ coverage to cover edge cases. We know that this isn't everyone's cup of tea,
so you can remove the statements
, branches
, lines
and functions
sections from the coverage
configuration object if you want to.
Alternatively, you can modify the report
script in the package.json
file to remove the --coverage
flag.
Note
A note on lockfiles.
Since this is a "create" package, lockfiles are not included. This is to ensure that the latest versions of dependencies are used when creating a new project.