Here’s how I approached it
I stopped rendering store-specific data on the page itself and turned every UI route into a plain “app shell.” Think of it like showing the frame of the page first, header, sidebar, placeholders, without any shop data inside. Because that HTML is exactly the same for every store, I can cache it at the CDN and serve it instantly. The moment the shell appears, I start calling my REST endpoints to fetch the real data and fill the placeholders. Even if those API calls take a second or two, the user already sees the page and statically rendered UI and with some loaders here and there.
Earlier my code looked like this:
// app/routes/dashboard.tsx
export async function loader({ request }: LoaderArgs) {
// This is a slow database call
const storeData = await getStoreDataFromDB();
return json(storeData);
}
export default function Dashboard() {
const storeData = useLoaderData<typeof loader>();
return <div>{storeData.name}</div>;
}
The issue here is that the loader must finish before the page renders. If that DB/API call takes 2–3 seconds, the browser has nothing to show until then. And because the renders page is different per store, you can’t cache this SSRed page at the CDN, so every route request goes all the way back to your server
I replaced it with this approach: For example
//routes/dashboard.tsx
// this is route but it has no loader and has no store specific data in it
export default function Dashboard() {
const [storeData, setStoreData] = useState(null);
useEffect(() => {
fetch('/api/store')
.then(response => response.json())
.then(data => setStoreData(data));
}, []);
return <div>{storeData?.name || 'Loading...'}</div>;
}
And then I created a separate API route to return the data:
//routes/api.store.ts
import { authenticate } from "~/shopify.server";
import { type LoaderFunctionArgs, json } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
try {
const { session } = await authenticate.admin(request);
const storeData = await getStoreDataFromDB(session.id);
return json(storeData);
} catch (error) {
console.error(error);
return json({ error: "Failed to fetch store data" }, { status: 500 });
}
}
Now the route is just a static shell, same for all stores. That means the CDN can cache it and return it instantly. After the page loads, the browser makes an API call to get the real data. Even if that takes a while, the page has already painted and LCP is unaffected.
One thing to watch out for: make sure you don’t render a bigger UI element later once the data comes in. As far as i know Shopify collects the latest LCP event(I could be wrong here), not the first one. So if your page shows a small heading at load and then a huge chart or banner appears after two seconds, that bigger element becomes the LCP, and your score goes down. It’s better if your static shell already contains the element that will be your largest paint, so it doesn’t shift later.
The problem with keeping loaders (including in root.tsx) in routes is that they force the server to do work before sending anything. If your route loader touches the database or the Shopify API, your TTFB easily jumps to 2–5 seconds. With that kind of delay, you’re fighting a lost battle on LCP because the browser has nothing to paint until the server finishes. Another issue with route loaders is regional latency. You can’t have servers in every region. If your app server is in the US and a merchant in Asia opens the app, the request travels all the way to the US server, waits there for the SSR to complete, then comes back to Asia, and only after that can the browser start loading the rest of your static assets from the CDN. That round trip alone ruins performance. When you serve a cached static shell from the CDN, this problem disappears because the HTML is already at the CDN and is same for every store. so it literally appears within 200ms-400ms
Caching needs a small mindset shift too. Since the UI routes are identical for all shops, I let the CDN ignore query strings like ?shop=... That keeps the cache clean and highly reusable and every store can load same already SSRed skeleton. My /api/* endpoints are excluded from CDN cache; they remain dynamic and can be uncached or lightly cached depending on the data, but they don’t affect the initial paint.
In simple words: show the page fast, validate and fill the details after.
I’m using this approach for two apps, one has LCP of 0.8s and the other 1.7s. The only difference is in CDN caching (the 1.7s one has slightly different settings with lower cache time), but I didn’t change it since the LCP is still within range