Integrating Third-Party Services in Remix
A practical way to wire third-party services into Remix by treating runtime boundaries, environment variables, and SDK placement as separate problems.
Third-party integrations in Remix often feel harder than they should.
Usually that is not because the service itself is especially complicated. It is because the docs were written for React, Next.js, or generic Node, and Remix forces you to be more honest about where code runs.
That is the real source of most integration pain.
Once you break the problem apart, most integrations boil down to three separate questions:
- which SDK belongs in which runtime
- which config is safe to expose
- where the initialized client should live
Treat those as separate decisions and most integrations stop feeling mysterious.
1. Install dependencies by runtime, not by marketing copy
If a service offers a Remix-specific package, use it.
If it does not, be explicit:
- use a server SDK for server code
- use a browser or React SDK for client code
- avoid pretending one package cleanly spans both if it really does not
This is where a lot of wasted time starts. People install the package that looks closest to their stack, then spend hours debugging import errors that are really runtime mismatches.
One common example is an ESM-only server dependency. In Remix, that may need bundling help through serverDependenciesToBundle. If you skip that detail, the integration can look broken long before you reach any business logic.
2. Pass only the config the browser is allowed to know
Most services give you at least two classes of configuration:
- private keys for server-side work
- public keys for browser initialization
Those should not be treated the same way.
Keep the secret on the server. Pass only the public value through the root loader or whatever boundary makes sense for your app.
export async function loader() {
return json({
ENV: {
SOME_SERVICE_PUBLIC_KEY: process.env.SOME_SERVICE_PUBLIC_KEY,
},
});
}Then expose it to the client deliberately:
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(data.ENV)}`,
}}
/>This part is simple, but it is also the place where people most often get sloppy. The useful rule is easy to remember:
If the browser can read it, assume the whole world can read it.
That means secrets stay in loaders, actions, or server-only modules.
3. Put the SDK where its lifecycle makes sense
Once you have the right package and the right config, the last question is where the initialized SDK should live.
For server code, a dedicated .server.ts file is usually the cleanest home:
import { SomeService } from "some-service/node";
export const someService = new SomeService(
process.env.SOME_SERVICE_SECRET_KEY,
);For the client, there are usually two reasonable shapes:
- initialize the SDK in a provider near the root if the whole app needs it
- export a client-only instance from a
.client.tsmodule if usage is more localized
That choice matters more than it seems. A lot of integration bugs are really just lifecycle bugs in disguise. The code works, but it is initialized in the wrong place, recreated too often, or tries to run in the wrong environment.
A practical example of the failure mode
This is the pattern I keep seeing:
- install one SDK
- import it everywhere
- reference
process.envfrom shared code - discover that part of the code now breaks in the browser
At that point the integration looks messy, but the root problem is still simple: server concerns and browser concerns were mixed too early.
Remix is actually helpful here because it makes that split visible.
The mental model I keep coming back to
Whenever an integration feels frustrating, I ask three questions:
- Which runtime does this code belong to?
- Which values are safe to expose?
- Where should this SDK instance live?
If those answers are clean, the integration is usually clean too.
Most of the pain comes from trying to answer all three at once.

