Optimising LCP images
Revolutionize Your Shopify App's LCP: The Static App Shell Strategy

Revolutionize Your Shopify App's LCP: The Static App Shell Strategy
Are you struggling to achieve the coveted "Built For Shopify" badge due to high Largest Contentful Paint (LCP) scores? Many Shopify app developers, especially those building with Remix, face the challenge of LCP values well above the target of < 2.5 seconds [1]. This often leads to frustrating debugging attempts, even after implementing common optimizations like lazy loading, caching, and reducing blocking render calls [1].
This post will delve into a highly effective strategy for drastically improving your Shopify app's LCP, as detailed and successfully implemented within the developer community.
The Core Problem: Server-Side Rendering (SSR) and Slow Loaders
A common culprit for high LCP in Remix-based Shopify apps is the extensive use of **server-side rendering (SSR) via route loaders that make database or API calls** [2-5].
Here's why this approach often leads to poor LCP:
- Blocking Render: When a Remix loader performs slow database or API calls (e.g., to Shopify GraphQL Admin API), the browser has nothing to render until that loader completes [4, 5]. If these calls take 2-3 seconds, your users are staring at a blank screen [4].
- High Time To First Byte (TTFB): Loaders touching the database or Shopify API can easily push your TTFB to 2-5 seconds [5, 6]. Optimizing LCP becomes incredibly difficult with such delays [3, 5].
- No CDN Caching for Dynamic Pages: Since SSRed pages often contain store-specific data, they are unique to each merchant. This prevents them from being effectively cached at a Content Delivery Network (CDN), meaning every route request must go all the way back to your server [3-5].
- Regional Latency: If your app server is in the US and a merchant in Asia opens the app, the request travels across continents, waits for SSR to complete, and then returns [3, 5]. This round trip alone can severely impact performance [5].
For example, an app struggling with LCP had API calls taking 2.7 seconds during app load [7]. Another experienced server response times between 2.5s and 5s [6]. These delays directly translate to a poor user experience and high LCP scores (e.g., 3.7s - 5.46s observed) [8, 9].
The Accepted Solution: Static App Shells with Client-Side Data Fetching
The most impactful solution involves a fundamental shift in how your app's pages are structured and data is loaded. The core idea is to **remove SSR for store-specific data on your UI routes and switch to client-side data fetching via REST endpoints** [9, 10].
Here’s a breakdown of this highly effective approach:
- Transform UI Routes into Static "App Shells":
- Instead of rendering store-specific data directly on the page, convert your UI routes (e.g.,
/dashboard.tsx) into **plain "app shells"** [9]. - An app shell provides the static frame of your page—think headers, sidebars, and placeholders—without any merchant-specific data initially loaded [9].
- Crucially, this HTML is identical for every store [5, 9, 11].
- Instead of rendering store-specific data directly on the page, convert your UI routes (e.g.,
- Leverage CDN Caching for App Shells:
- Because your app shells are static and identical across all stores, you can **cache them aggressively at the CDN** [5, 9-11].
- This means the main page HTML can be **served almost instantly (within 200-400ms)** from the CDN, drastically reducing TTFB and mitigating regional latency issues [5].
- To maximize cache reusability, configure your CDN to **ignore query strings** like
?shop=...for these UI routes [12].
- Fetch Dynamic Data Client-Side with REST Endpoints:
- Once the static app shell appears in the user's browser, initiate **client-side calls to separate REST API endpoints** to fetch the real, dynamic store-specific data [9-11].
- You can use
useEffecthooks in your components to make these fetches [13, 14]. - Even if these API calls take a second or two, **the user has already seen the page and a statically rendered UI**, often with loaders filling the placeholders. This means the LCP is unaffected by the subsequent data loading [9-11].
- Dedicated API Routes:
- Create **separate API routes** (e.g.,
/routes/api.store.ts) that handle data fetching (e.g., from your database or Shopify Admin API) [13]. - These
api/*endpoints should be **excluded from CDN caching** (or lightly cached depending on data volatility) as they provide dynamic, user-specific content [12].
- Create **separate API routes** (e.g.,
Illustrative Code Snippets (Concept from Source):
Old approach (blocking SSR loader):
// 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>;
}
Issue: The page won't render until getStoreDataFromDB() finishes [4].
New approach (static shell + client-side fetch):
// routes/dashboard.tsx (no loader, no store-specific data)
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>;
}
// routes/api.store.ts (separate API route for data)
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 });
}
}
Benefit: The dashboard.tsx route is a static shell, cached by CDN. The data is fetched after the page loads, without blocking the initial render [11].
Key Benefits of this Strategy:
- Significantly Lower LCP: By serving a static shell instantly, the largest content element (which should be part of this shell) renders much faster [5, 11]. Developers have achieved LCPs of **0.8 seconds and 1.7 seconds** using this method [12].
- Improved User Experience: Users see content much quicker, even if dynamic data is still loading [9].
- Reduced TTFB: The server doesn't need to perform heavy database/API work before sending the initial HTML, leading to a much faster TTFB [5].
- Global Performance: Regional latency issues are minimized as the cached HTML is served from a CDN edge location close to the user [5].
A Crucial Caveat for LCP:
While this approach is powerful, there's one important point to remember:
- Avoid "LCP Shift": Make sure you **do not render a bigger UI element later** once the dynamic data comes in. Shopify may collect the latest LCP event, not just the first one [11]. If your page initially shows a small heading and then a huge chart or banner appears two seconds later, that larger element could become the new LCP, negatively impacting your score. It's best if your static shell already contains the element that will be your largest paint [11].
Conclusion
Transforming your Remix Shopify app to use static app shells with client-side data fetching is a robust strategy to conquer high LCP scores and achieve the "Built For Shopify" badge. It requires a mindset shift from fully server-rendered pages to showing the page fast and then filling in details dynamically [12]. While moving away from SSR loaders might seem like a significant change, the performance benefits for your users and app are substantial.
About SBO Tech Team
Expert in Shopify app development.
Want to Read More?
Explore more insights and tutorials on Shopify development and e-commerce optimization.
Browse All Posts