Expert patterns for Shopify app development including Remix/React Router apps, embedded apps with App Bridge, webhook handling, GraphQL Admin API, Polaris components, billing, and app extensions.
Expert patterns for Shopify app development including Remix/React Router apps, embedded apps with App Bridge, webhook handling, GraphQL Admin API, Polaris components, billing, and app extensions.
Modern Shopify app template with React Router
When to use: Starting a new Shopify app
npm init @shopify/app@latest my-shopify-app
// shopify.app.toml name = "my-shopify-app" client_id = "your-client-id" application_url = "https://your-app.example.com"
[access_scopes] scopes = "read_products,write_products,read_orders"
[webhooks] api_version = "2024-10"
[webhooks.subscriptions] topics = ["orders/create", "products/update"] uri = "/webhooks"
[auth] redirect_urls = ["https://your-app.example.com/auth/callback"]
// app/shopify.server.ts import "@shopify/shopify-app-remix/adapters/node"; import { LATEST_API_VERSION, shopifyApp, DeliveryMethod, } from "@shopify/shopify-app-remix/server"; import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma"; import prisma from "./db.server";
const shopify = shopifyApp({ apiKey: process.env.SHOPIFY_API_KEY!, apiSecretKey: process.env.SHOPIFY_API_SECRET!, scopes: process.env.SCOPES?.split(","), appUrl: process.env.SHOPIFY_APP_URL!, authPathPrefix: "/auth", sessionStorage: new PrismaSessionStorage(prisma), distribution: AppDistribution.AppStore, future: { unstable_newEmbeddedAuthStrategy: true, }, ...(process.env.SHOP_CUSTOM_DOMAIN ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] } : {}), });
export default shopify; export const apiVersion = LATEST_API_VERSION; export const authenticate = shopify.authenticate; export const sessionStorage = shopify.sessionStorage;
Render app embedded in Shopify Admin
When to use: Building embedded admin app
// app/routes/app.tsx - App layout with providers import { Link, Outlet, useLoaderData, useRouteError } from "@remix-run/react"; import { AppProvider } from "@shopify/shopify-app-remix/react"; import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";
export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
export async function loader({ request }: LoaderFunctionArgs) { await authenticate.admin(request); return json({ apiKey: process.env.SHOPIFY_API_KEY! }); }
export default function App() { const { apiKey } = useLoaderData<typeof loader>();
return ( <AppProvider isEmbeddedApp apiKey={apiKey}> <ui-nav-menu> <Link to="/app" rel="home">Home</Link> <Link to="/app/products">Products</Link> <Link to="/app/settings">Settings</Link> </ui-nav-menu> <Outlet /> </AppProvider> ); }
export function ErrorBoundary() { const error = useRouteError(); return ( <AppProvider isEmbeddedApp> <Page> <Card> <Text as="p" variant="bodyMd"> Something went wrong. Please try again. </Text> </Card> </Page> </AppProvider> ); }
// app/routes/app._index.tsx - Main app page import { Page, Layout, Card, Text, BlockStack, Button, } from "@shopify/polaris"; import { TitleBar } from "@shopify/app-bridge-react";
export async function loader({ request }: LoaderFunctionArgs) { const { admin } = await authenticate.admin(request);
// GraphQL query
const response = await admin.graphql( query { shop { name email } } );
const { data } = await response.json(); return json({ shop: data.shop }); }
export default function Index() { const { shop } = useLoaderData<typeof loader>();
return ( <Page> <TitleBar title="My Shopify App" /> <Layout> <Layout.Section> <Card> <BlockStack gap="200"> <Text as="h2" variant="headingMd"> Welcome to {shop.name}! </Text> <Text as="p" variant="bodyMd"> Your app is now connected to this store. </Text> <Button variant="primary"> Get Started </Button> </BlockStack> </Card> </Layout.Section> </Layout> </Page> ); }
Secure webhook processing with HMAC verification
When to use: Receiving Shopify webhooks
// app/routes/webhooks.tsx import type { ActionFunctionArgs } from "@remix-run/node"; import { authenticate } from "../shopify.server"; import db from "../db.server";
export const action = async ({ request }: ActionFunctionArgs) => { // Authenticate webhook (verifies HMAC signature) const { topic, shop, payload, admin } = await authenticate.webhook(request);
console.log(Received ${topic} webhook for ${shop});
// Process based on topic switch (topic) { case "ORDERS_CREATE": // Queue for async processing await queueOrderProcessing(payload); break;
case "PRODUCTS_UPDATE":
await handleProductUpdate(shop, payload);
break;
case "APP_UNINSTALLED":
// Clean up shop data
await db.session.deleteMany({ where: { shop } });
await db.shopData.delete({ where: { shop } });
break;
case "CUSTOMERS_DATA_REQUEST":
case "CUSTOMERS_REDACT":
case "SHOP_REDACT":
// GDPR webhooks - mandatory
await handleGDPRWebhook(topic, payload);
break;
default:
console.log(`Unhandled webhook topic: ${topic}`);
}
// CRITICAL: Return 200 immediately // Shopify expects response within 5 seconds return new Response(null, { status: 200 }); };
// Process asynchronously after responding async function queueOrderProcessing(payload: any) { // Use a job queue (BullMQ, etc.) await jobQueue.add("process-order", { orderId: payload.id, orderData: payload, }); }
async function handleProductUpdate(shop: string, payload: any) { // Quick sync operation only await db.product.upsert({ where: { shopifyId: payload.id }, update: { title: payload.title, updatedAt: new Date(), }, create: { shopifyId: payload.id, shop, title: payload.title, }, }); }
async function handleGDPRWebhook(topic: string, payload: any) { // GDPR compliance - required for all apps switch (topic) { case "CUSTOMERS_DATA_REQUEST": // Return customer data within 30 days break; case "CUSTOMERS_REDACT": // Delete customer data break; case "SHOP_REDACT": // Delete all shop data (48 hours after uninstall) break; } }
Query and mutate shop data with GraphQL
When to use: Interacting with Shopify Admin API
// GraphQL queries with authenticated admin client export async function loader({ request }: LoaderFunctionArgs) { const { admin } = await authenticate.admin(request);
// Query products with pagination
const response = await admin.graphql( query GetProducts($first: Int!, $after: String) { products(first: $first, after: $after) { edges { node { id title status totalInventory priceRangeV2 { minVariantPrice { amount currencyCode } } images(first: 1) { edges { node { url altText } } } } cursor } pageInfo { hasNextPage endCursor } } } , {
variables: {
first: 10,
after: null,
},
});
const { data } = await response.json(); return json({ products: data.products }); }
// Mutations export async function action({ request }: ActionFunctionArgs) { const { admin } = await authenticate.admin(request); const formData = await request.formData(); const productId = formData.get("productId"); const newTitle = formData.get("title");
const response = await admin.graphql( mutation UpdateProduct($input: ProductInput!) { productUpdate(input: $input) { product { id title } userErrors { field message } } } , {
variables: {
input: {
id: productId,
title: newTitle,
},
},
});
const { data } = await response.json();
if (data.productUpdate.userErrors.length > 0) { return json({ errors: data.productUpdate.userErrors, }, { status: 400 }); }
return json({ product: data.productUpdate.product }); }
// Bulk operations for large datasets
async function bulkUpdateProducts(admin: AdminApiContext) {
// Create bulk operation
const response = await admin.graphql( mutation { bulkOperationRunMutation( mutation: "mutation call($input: ProductInput!) { productUpdate(input: $input) { product { id } } }", stagedUploadPath: "path-to-staged-upload" ) { bulkOperation { id status } userErrors { message } } } );
// Poll for completion or use webhook // BULK_OPERATIONS_FINISH webhook }
Implement subscription billing for your app
When to use: Monetizing Shopify app
// app/routes/app.billing.tsx import { json, redirect } from "@remix-run/node"; import { Page, Card, Button, BlockStack, Text } from "@shopify/polaris"; import { authenticate } from "../shopify.server";
const PLANS = { basic: { name: "Basic", amount: 9.99, currencyCode: "USD", interval: "EVERY_30_DAYS", }, pro: { name: "Pro", amount: 29.99, currencyCode: "USD", interval: "EVERY_30_DAYS", }, };
export async function loader({ request }: LoaderFunctionArgs) { const { admin, billing } = await authenticate.admin(request);
// Check current subscription
const response = await admin.graphql( query { currentAppInstallation { activeSubscriptions { id name status lineItems { plan { pricingDetails { ... on AppRecurringPricing { price { amount currencyCode } interval } } } } } } } );
const { data } = await response.json(); return json({ subscription: data.currentAppInstallation.activeSubscriptions[0], }); }
export async function action({ request }: ActionFunctionArgs) { const { admin, session } = await authenticate.admin(request); const formData = await request.formData(); const planKey = formData.get("plan") as keyof typeof PLANS; const plan = PLANS[planKey];
// Create subscription charge
const response = await admin.graphql( mutation CreateSubscription($name: String!, $lineItems: [AppSubscriptionLineItemInput!]!, $returnUrl: URL!, $test: Boolean) { appSubscriptionCreate( name: $name lineItems: $lineItems returnUrl: $returnUrl test: $test ) { appSubscription { id status } confirmationUrl userErrors { field message } } } , {
variables: {
name: plan.name,
lineItems: [
{
plan: {
appRecurringPricingDetails: {
price: {
amount: plan.amount,
currencyCode: plan.currencyCode,
},
interval: plan.interval,
},
},
},
],
returnUrl: https://${session.shop}/admin/apps/${process.env.SHOPIFY_API_KEY},
test: process.env.NODE_ENV !== "production",
},
});
const { data } = await response.json();
if (data.appSubscriptionCreate.userErrors.length > 0) { return json({ errors: data.appSubscriptionCreate.userErrors, }, { status: 400 }); }
// Redirect merchant to approve charge return redirect(data.appSubscriptionCreate.confirmationUrl); }
export default function Billing() { const { subscription } = useLoaderData<typeof loader>(); const submit = useSubmit();
return ( <Page title="Billing"> <Card> {subscription ? ( <BlockStack gap="200"> <Text as="p" variant="bodyMd"> Current plan: {subscription.name} </Text> <Text as="p" variant="bodyMd"> Status: {subscription.status} </Text> </BlockStack> ) : ( <BlockStack gap="400"> <Text as="h2" variant="headingMd"> Choose a Plan </Text> <Button onClick={() => submit({ plan: "basic" }, { method: "post" })}> Basic - $9.99/month </Button> <Button onClick={() => submit({ plan: "pro" }, { method: "post" })}> Pro - $29.99/month </Button> </BlockStack> )} </Card> </Page> ); }
Extend Shopify checkout, admin, or storefront
When to use: Building app extensions
api_version = "2024-10"
[[extensions]] type = "ui_extension" name = "Product Customizer" handle = "product-customizer"
[[extensions.targeting]] target = "admin.product-details.block.render" module = "./src/AdminBlock.tsx"
[extensions.capabilities] api_access = true
[extensions.settings] [[extensions.settings.fields]] key = "show_preview" type = "boolean" name = "Show Preview"
// extensions/my-extension/src/AdminBlock.tsx import { reactExtension, useApi, useSettings, BlockStack, Text, Button, InlineStack, } from "@shopify/ui-extensions-react/admin";
export default reactExtension( "admin.product-details.block.render", () => <ProductCustomizer /> );
function ProductCustomizer() { const { data, extension } = useApi<"admin.product-details.block.render">(); const settings = useSettings();
const productId = data?.selected?.[0]?.id;
const handleCustomize = async () => { // API calls from extension const result = await fetch("/api/customize", { method: "POST", body: JSON.stringify({ productId }), }); };
return ( <BlockStack gap="base"> <Text fontWeight="bold">Product Customizer</Text> <Text> Customize product: {productId} </Text> {settings.show_preview && ( <Text size="small">Preview enabled</Text> )} <InlineStack gap="base"> <Button onPress={handleCustomize}> Apply Customization </Button> </InlineStack> </BlockStack> ); }
// Checkout UI Extension // [[extensions.targeting]] // target = "purchase.checkout.block.render"
// extensions/checkout-ext/src/Checkout.tsx import { reactExtension, Banner, useCartLines, useTotalAmount, } from "@shopify/ui-extensions-react/checkout";
export default reactExtension( "purchase.checkout.block.render", () => <CheckoutBanner /> );
function CheckoutBanner() { const cartLines = useCartLines(); const total = useTotalAmount();
if (total.amount > 100) { return ( <Banner status="success"> You qualify for free shipping! </Banner> ); }
return null; }
Severity: HIGH
Situation: Receiving webhooks from Shopify
Symptoms: Webhook deliveries marked as failed. "Your app didn't respond in time" in Shopify logs. Missing order/product updates. Webhooks retried repeatedly then cancelled.
Why this breaks: Shopify expects a 2xx response within 5 seconds. If your app processes the webhook data before responding, you'll timeout.
Shopify retries failed webhooks up to 19 times over 48 hours. After continued failures, webhooks may be cancelled entirely.
Heavy processing (API calls, database operations) must happen after the response is sent.
Recommended fix:
// app/routes/webhooks.tsx
export const action = async ({ request }: ActionFunctionArgs) => {
const { topic, shop, payload } = await authenticate.webhook(request);
// Queue for async processing
await jobQueue.add("process-webhook", {
topic,
shop,
payload,
});
// CRITICAL: Return 200 immediately
return new Response(null, { status: 200 });
};
// Worker process handles the actual work
// workers/webhook-processor.ts
import { Worker } from "bullmq";
const worker = new Worker("process-webhook", async (job) => {
const { topic, shop, payload } = job.data;
switch (topic) {
case "ORDERS_CREATE":
await processOrder(shop, payload);
break;
// ... other handlers
}
});
// Simple database update is OK if fast
export const action = async ({ request }: ActionFunctionArgs) => {
const { topic, payload } = await authenticate.webhook(request);
// Quick database update (< 1 second)
await db.product.update({
where: { shopifyId: payload.id },
data: { title: payload.title },
});
return new Response(null, { status: 200 });
};
// Log response times
const start = Date.now();
await handleWebhook(payload);
const duration = Date.now() - start;
console.log(`Webhook processed in ${duration}ms`);
// Alert if approaching timeout
if (duration > 3000) {
console.warn("Webhook processing taking too long!");
}
Severity: HIGH
Situation: Making API calls to Shopify
Symptoms: HTTP 429 Too Many Requests errors. "Throttled" responses. App becomes unresponsive. Operations fail silently or partially.
Why this breaks: Shopify enforces strict rate limits:
Exceeding limits causes immediate 429 errors. Continuous violations can result in temporary bans.
Bulk operations count against limits.
Recommended fix:
// REST API
// X-Shopify-Shop-Api-Call-Limit: 39/40
// GraphQL - check response extensions
const response = await admin.graphql(`...`);
const { data, extensions } = await response.json();
const cost = extensions?.cost;
// {
// "requestedQueryCost": 42,
// "actualQueryCost": 42,
// "throttleStatus": {
// "maximumAvailable": 1000,
// "currentlyAvailable": 958,
// "restoreRate": 50
// }
// }
async function shopifyRequest(
fn: () => Promise<Response>,
maxRetries = 3
): Promise<Response> {
let lastError: Error;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fn();
if (response.status === 429) {
// Get retry-after header or default
const retryAfter = parseInt(
response.headers.get("Retry-After") || "2"
);
await sleep(retryAfter * 1000 * Math.pow(2, attempt));
continue;
}
return response;
} catch (error) {
lastError = error as Error;
}
}
throw lastError!;
}
// Instead of 1000 individual calls, use bulk mutation
const response = await admin.graphql(`
mutation {
bulkOperationRunMutation(
mutation: "mutation($input: ProductInput!) {
productUpdate(input: $input) { product { id } }
}",
stagedUploadPath: "..."
) {
bulkOperation { id status }
userErrors { message }
}
}
`);
import { RateLimiter } from "limiter";
// 2 requests per second for REST
const limiter = new RateLimiter({
tokensPerInterval: 2,
interval: "second",
});
async function rateLimitedRequest(fn: () => Promise<any>) {
await limiter.removeTokens(1);
return fn();
}
Severity: HIGH
Situation: Accessing customer PII in webhooks or API
Symptoms: Webhook deliveries fail for orders/customers. Customer data fields are null or empty. App works in development but fails in production. "Protected customer data access" errors.
Why this breaks: Since April 2024, accessing protected customer data (PII) requires explicit approval from Shopify. This is separate from OAuth scopes.
Protected data includes:
Even with read_orders scope, you won't receive customer data in webhooks without protected data access.
Recommended fix:
// Query your app's data access
const response = await admin.graphql(`
query {
currentAppInstallation {
accessScopes {
handle
}
}
}
`);
// Webhook payload may have redacted fields
async function processOrder(payload: any) {
const customerEmail = payload.customer?.email;
if (!customerEmail) {
// Customer data not available
// Either no protected access or data redacted
console.log("Customer data not available");
return;
}
await sendOrderConfirmation(customerEmail);
}
// If customer is logged in, can access their data
// through Customer Account API (different from Admin API)
Severity: MEDIUM
Situation: Configuring webhooks in both TOML and code
Symptoms: Duplicate webhook deliveries. Some webhooks fire twice. Webhook subscriptions fail to register. Unpredictable webhook behavior.
Why this breaks: Shopify apps can define webhooks in two places:
If you define the same webhook in both places, you get:
Recommended fix:
# shopify.app.toml
[webhooks]
api_version = "2024-10"
[webhooks.subscriptions]
topics = [
"orders/create",
"orders/updated",
"products/create",
"products/update",
"app/uninstalled"
]
uri = "/webhooks"
// DON'T do this if using TOML
const shopify = shopifyApp({
// ...
hooks: {
afterAuth: async ({ session }) => {
// Remove webhook registration from here
// Let TOML handle it
},
},
});
# Webhooks registered on deploy
shopify app deploy
const response = await admin.graphql(`
query {
webhookSubscriptions(first: 50) {
edges {
node {
id
topic
endpoint {
... on WebhookHttpEndpoint {
callbackUrl
}
}
}
}
}
}
`);
Severity: MEDIUM
Situation: Setting up webhook endpoints
Symptoms: Webhooks return 404 Not Found. Webhook delivery fails immediately. Works in local dev but fails in production. Logs show request to /webhooks/ not /webhooks.
Why this breaks: Shopify automatically adds a trailing slash to webhook URLs. If your server doesn't handle both /webhooks and /webhooks/, the webhook will 404.
Common with frameworks that are strict about trailing slashes.
Recommended fix:
// Remix/React Router - both work by default
// app/routes/webhooks.tsx handles /webhooks
// Express - add middleware
app.use((req, res, next) => {
if (req.path.endsWith('/') && req.path.length > 1) {
const query = req.url.slice(req.path.length);
const safePath = req.path.slice(0, -1);
res.redirect(301, safePath + query);
}
next();
});
# Nginx - strip trailing slashes
location ~ ^(.+)/$ {
return 301 $1;
}
# Or rewrite to handler
location /webhooks {
try_files $uri $uri/ @webhooks;
}
location @webhooks {
proxy_pass http://app:3000/webhooks;
}
# Test without slash
curl -X POST https://your-app.com/webhooks
# Test with slash
curl -X POST https://your-app.com/webhooks/
Severity: HIGH
Situation: Building new public apps or maintaining existing
Symptoms: App store submission rejected for REST API usage. Deprecation warnings in console. Some REST endpoints stop working. Missing features only in GraphQL.
Why this breaks: As of October 2024, REST Admin API is legacy. Starting April 2025, new public apps MUST use GraphQL.
REST endpoints will continue working for existing apps, but new features are GraphQL-only.
Metafields, bulk operations, and many new features require GraphQL.
Recommended fix:
// REST (legacy)
const response = await fetch(
`https://${shop}/admin/api/2024-10/products.json`,
{
headers: { "X-Shopify-Access-Token": token },
}
);
// GraphQL (recommended)
const response = await admin.graphql(`
query {
products(first: 10) {
edges {
node {
id
title
}
}
}
}
`);
// REST: GET /products/{id}.json
// GraphQL equivalent:
const response = await admin.graphql(`
query GetProduct($id: ID!) {
product(id: $id) {
id
title
status
variants(first: 10) {
edges {
node {
id
price
inventoryQuantity
}
}
}
}
}
`, {
variables: { id: `gid://shopify/Product/${productId}` },
});
# shopify.app.toml
[webhooks]
api_version = "2024-10" # Use latest GraphQL version
Severity: HIGH
Situation: Building embedded Shopify apps
Symptoms: App rejected from "Built for Shopify" program. App not appearing correctly in admin. Navigation and chrome issues. Warning about App Bridge version.
Why this breaks: Effective July 2025, all apps seeking "Built for Shopify" status must use the latest version of App Bridge and be embedded.
Apps using old App Bridge versions or not embedded will lose built for Shopify benefits (better placement, badges).
Shopify now serves App Bridge and Polaris via unversioned script tags that auto-update.
Recommended fix:
<!-- Automatically stays up to date -->
<script src="https://cdn.shopify.com/shopifycloud/app-bridge.js"></script>
// app/routes/app.tsx
import { AppProvider } from "@shopify/shopify-app-remix/react";
export default function App() {
return (
<AppProvider isEmbeddedApp apiKey={apiKey}>
<Outlet />
</AppProvider>
);
}
// shopify.server.ts
const shopify = shopifyApp({
// ...
future: {
unstable_newEmbeddedAuthStrategy: true,
},
});
import { useAppBridge } from "@shopify/app-bridge-react";
function MyComponent() {
const app = useAppBridge();
const isEmbedded = app.hostOrigin !== window.location.origin;
}
Severity: HIGH
Situation: Submitting app to Shopify App Store
Symptoms: App submission rejected. "GDPR webhooks not implemented" error. Manual review fails for compliance. Data request webhooks not handled.
Why this breaks: Shopify requires all apps to handle three GDPR webhooks:
These are automatically subscribed when you create an app. You MUST implement handlers even if you don't store data.
Recommended fix:
// app/routes/webhooks.tsx
export const action = async ({ request }: ActionFunctionArgs) => {
const { topic, payload, shop } = await authenticate.webhook(request);
switch (topic) {
case "CUSTOMERS_DATA_REQUEST":
await handleDataRequest(shop, payload);
break;
case "CUSTOMERS_REDACT":
await handleCustomerRedact(shop, payload);
break;
case "SHOP_REDACT":
await handleShopRedact(shop, payload);
break;
}
return new Response(null, { status: 200 });
};
async function handleDataRequest(shop: string, payload: any) {
const customerId = payload.customer.id;
// Return customer data within 30 days
// Usually send to data_request.destination_url
const customerData = await db.customer.findUnique({
where: { shopifyId: customerId, shop },
});
if (customerData) {
// Send to provided URL or email
await sendDataToMerchant(payload.data_request, customerData);
}
}
async function handleCustomerRedact(shop: string, payload: any) {
const customerId = payload.customer.id;
// Delete customer's personal data
await db.customer.deleteMany({
where: { shopifyId: customerId, shop },
});
await db.order.updateMany({
where: { customerId, shop },
data: { customerEmail: null, customerName: null },
});
}
async function handleShopRedact(shop: string, payload: any) {
// Shop uninstalled 48+ hours ago
// Delete ALL data for this shop
await db.session.deleteMany({ where: { shop } });
await db.customer.deleteMany({ where: { shop } });
await db.order.deleteMany({ where: { shop } });
await db.settings.deleteMany({ where: { shop } });
}
// You must still respond 200
case "CUSTOMERS_DATA_REQUEST":
case "CUSTOMERS_REDACT":
case "SHOP_REDACT":
// No data stored, but must acknowledge
console.log(`GDPR ${topic} for ${shop} - no data stored`);
break;
Severity: ERROR
API secrets must never be hardcoded
Message: Hardcoded Shopify API secret. Use environment variables.
Severity: ERROR
API keys should use environment variables
Message: Hardcoded Shopify API key. Use environment variables.
Severity: ERROR
Webhook endpoints must verify HMAC signature
Message: Webhook handler without HMAC verification. Use authenticate.webhook().
Severity: WARNING
Webhook handlers should respond quickly
Message: Multiple await calls in webhook handler. Consider async processing.
Severity: ERROR
Webhooks must return 200 status
Message: Webhook handler may not return proper response.
Severity: WARNING
Webhooks should be defined in TOML only
Message: Code-based webhook registration. Define webhooks in shopify.app.toml.
Severity: INFO
REST API is deprecated, use GraphQL
Message: REST API usage detected. Consider migrating to GraphQL.
Severity: WARNING
API calls should handle 429 responses
Message: API call without rate limit handling. Implement retry logic.
Severity: WARNING
In-memory sessions don't scale
Message: In-memory session storage. Use PrismaSessionStorage or similar.
Severity: ERROR
Routes should validate session
Message: Loader without authentication. Use authenticate.admin(request).