Skip to main content

Server-Side Rendering

Expo Router supports server-side rendering (SSR) and static site generation (SSG) for web applications, enabling better performance and SEO.
Server rendering features require @expo/server and a compatible server environment.

Rendering Modes

Client-Side Rendering (CSR)

Default mode - all rendering happens in the browser:
app/index.tsx
export default function Home() {
  return <Text>Client-rendered</Text>;
}

Server-Side Rendering (SSR)

Render on the server for each request:
app/blog/[slug].tsx
import { Text } from 'react-native';

export async function loader({ params }) {
  const post = await fetchPost(params.slug);
  return { post };
}

export default function BlogPost({ loaderData }) {
  return <Text>{loaderData.post.title}</Text>;
}

Static Site Generation (SSG)

Pre-render at build time:
app/blog/[slug].tsx
export async function generateStaticParams() {
  const posts = await fetchAllPosts();
  return posts.map(post => ({ slug: post.slug }));
}

export async function loader({ params }) {
  const post = await fetchPost(params.slug);
  return { post };
}

export default function BlogPost({ loaderData }) {
  return <Text>{loaderData.post.title}</Text>;
}
From CLAUDE.md:20-24:
├── loaders/               # Data loader support (SSG and SSR)
├── rsc/                   # React Server Components  
├── static/                # Static rendering and SSR support

Data Loading

Loader Function

Fetch data on the server:
app/profile/[id].tsx
import { useLoaderData } from 'expo-router';
import { View, Text } from 'react-native';

type LoaderData = {
  user: {
    id: string;
    name: string;
    email: string;
  };
};

export async function loader({ params }) {
  const response = await fetch(`https://api.example.com/users/${params.id}`);
  const user = await response.json();
  
  return { user };
}

export default function Profile() {
  const { user } = useLoaderData<LoaderData>();
  
  return (
    <View>
      <Text>{user.name}</Text>
      <Text>{user.email}</Text>
    </View>
  );
}
From the source (hooks.ts:356-372):
/**
 * Returns the result of the `loader` function for the calling route.
 *
 * @example
 * export function loader() {
 *   return Promise.resolve({ foo: 'bar' });
 * }
 *
 * export default function Route() {
 *  const data = useLoaderData<typeof loader>(); // { foo: 'bar' }
 *  return <Text>Data: {JSON.stringify(data)}</Text>;
 * }
 */
export function useLoaderData<T extends LoaderFunction = any>(): 
  LoaderFunctionResult<T>;

Type-Safe Loaders

app/posts/[id].tsx
import { useLoaderData } from 'expo-router';

export async function loader({ params }: { params: { id: string } }) {
  const post = await fetchPost(params.id);
  const comments = await fetchComments(params.id);
  
  return {
    post,
    comments,
  };
}

export default function Post() {
  // Automatically typed from loader return value
  const { post, comments } = useLoaderData<typeof loader>();
  
  return (
    <View>
      <Text>{post.title}</Text>
      {comments.map(comment => (
        <Text key={comment.id}>{comment.text}</Text>
      ))}
    </View>
  );
}

Static Generation

generateStaticParams

Generate static paths at build time:
app/products/[id].tsx
export async function generateStaticParams() {
  const products = await fetchAllProducts();
  
  return products.map(product => ({
    id: product.id,
  }));
}

export async function loader({ params }) {
  const product = await fetchProduct(params.id);
  return { product };
}

export default function Product() {
  const { product } = useLoaderData();
  return <ProductView product={product} />;
}
From CLAUDE.md:20-24:
/** Skip routes created by `generateStaticParams()` */
skipStaticParams?: boolean;

Incremental Static Regeneration

Revalidate static pages:
app/blog/[slug].tsx
export const revalidate = 3600; // Revalidate every hour

export async function generateStaticParams() {
  const posts = await fetchRecentPosts();
  return posts.map(post => ({ slug: post.slug }));
}

export async function loader({ params }) {
  const post = await fetchPost(params.slug);
  return { post };
}

export default function BlogPost() {
  const { post } = useLoaderData();
  return <BlogPostView post={post} />;
}

React Server Components

Use React Server Components for server-only code:
app/dashboard.tsx
'use server';

import { db } from '../lib/database';

export default async function Dashboard() {
  // Runs only on server - can access database directly
  const stats = await db.stats.findMany();
  
  return (
    <View>
      {stats.map(stat => (
        <StatCard key={stat.id} data={stat} />
      ))}
    </View>
  );
}

Client Components

Mark client-only components:
components/InteractiveChart.tsx
'use client';

import { useState } from 'react';
import { View, Pressable } from 'react-native';

export function InteractiveChart({ data }) {
  const [selected, setSelected] = useState(null);
  
  return (
    <View>
      {data.map(item => (
        <Pressable
          key={item.id}
          onPress={() => setSelected(item)}
        >
          <ChartBar data={item} />
        </Pressable>
      ))}
    </View>
  );
}

Server Configuration

Next.js Adapter

Deploy with Next.js:
next.config.js
const { withExpo } = require('@expo/next-adapter');

module.exports = withExpo({
  // Next.js config
  reactStrictMode: true,
  transpilePackages: [
    'react-native',
    'expo',
    'expo-router',
  ],
});

Custom Server

Create a custom server:
server.ts
import { createRequestHandler } from '@expo/server/adapter/express';
import express from 'express';

const app = express();

app.use(
  createRequestHandler({
    build: require('./dist/server'),
  })
);

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

SEO Optimization

Meta Tags

Set meta tags for SEO:
app/blog/[slug].tsx
import { Head } from 'expo-router/head';
import { useLoaderData } from 'expo-router';

export async function loader({ params }) {
  const post = await fetchPost(params.slug);
  return { post };
}

export default function BlogPost() {
  const { post } = useLoaderData();
  
  return (
    <>
      <Head>
        <title>{post.title}</title>
        <meta name="description" content={post.excerpt} />
        <meta property="og:title" content={post.title} />
        <meta property="og:description" content={post.excerpt} />
        <meta property="og:image" content={post.image} />
        <meta name="twitter:card" content="summary_large_image" />
      </Head>
      
      <BlogPostView post={post} />
    </>
  );
}

Sitemap Generation

Generate sitemap.xml:
app/sitemap.xml+api.ts
import { ExpoRequest, ExpoResponse } from 'expo-router/server';

export async function GET(request: ExpoRequest) {
  const posts = await fetchAllPosts();
  
  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
      <url>
        <loc>https://example.com/</loc>
        <changefreq>daily</changefreq>
        <priority>1.0</priority>
      </url>
      ${posts.map(post => `
        <url>
          <loc>https://example.com/blog/${post.slug}</loc>
          <lastmod>${post.updatedAt}</lastmod>
          <changefreq>weekly</changefreq>
          <priority>0.8</priority>
        </url>
      `).join('')}
    </urlset>
  `;
  
  return new Response(sitemap, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=3600',
    },
  });
}

Performance Optimization

Data Caching

import { cache } from 'react';

const fetchUser = cache(async (id: string) => {
  const response = await fetch(`https://api.example.com/users/${id}`);
  return response.json();
});

export async function loader({ params }) {
  // Cached across requests
  const user = await fetchUser(params.id);
  return { user };
}

Streaming

Stream data to the client:
app/feed.tsx
import { Suspense } from 'react';

async function Posts() {
  const posts = await fetchPosts();
  return (
    <View>
      {posts.map(post => <PostCard key={post.id} post={post} />)}
    </View>
  );
}

export default function Feed() {
  return (
    <Suspense fallback={<LoadingSkeleton />}>
      <Posts />
    </Suspense>
  );
}

Environment Variables

Access server-side environment variables:
app/api/config+api.ts
export async function loader() {
  // Server-only variables
  const apiKey = process.env.API_KEY;
  const dbUrl = process.env.DATABASE_URL;
  
  // Never expose secrets to client
  return {
    publicConfig: {
      apiUrl: process.env.NEXT_PUBLIC_API_URL,
    },
  };
}

Error Handling

Custom Error Pages

app/+error.tsx
import { ErrorBoundaryProps } from 'expo-router';
import { View, Text } from 'react-native';

export default function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
  return (
    <View>
      <Text>Something went wrong!</Text>
      <Text>{error.message}</Text>
      <Button onPress={retry} title="Try Again" />
    </View>
  );
}

Loader Error Handling

export async function loader({ params }) {
  try {
    const data = await fetchData(params.id);
    return { data };
  } catch (error) {
    // Return error state
    return {
      error: {
        message: 'Failed to load data',
        status: 500,
      },
    };
  }
}

export default function Page() {
  const { data, error } = useLoaderData();
  
  if (error) {
    return <ErrorView error={error} />;
  }
  
  return <DataView data={data} />;
}

Testing

Test Loaders

__tests__/loaders/user.test.ts
import { loader } from '../../app/user/[id]';

describe('User loader', () => {
  it('fetches user data', async () => {
    const result = await loader({ params: { id: '123' } });
    
    expect(result.user).toBeDefined();
    expect(result.user.id).toBe('123');
  });
  
  it('handles errors', async () => {
    const result = await loader({ params: { id: 'invalid' } });
    
    expect(result.error).toBeDefined();
  });
});

Best Practices

Minimize Client Bundle

// Good: Server component with server-only imports
'use server';
import { db } from '../lib/database'; // Won't be in client bundle

export default async function Page() {
  const data = await db.query();
  return <View>{data}</View>;
}

Prefetch Data

import { router } from 'expo-router';

function ProductCard({ product }) {
  return (
    <Pressable
      onMouseEnter={() => {
        // Prefetch on hover
        router.prefetch(`/products/${product.id}`);
      }}
      onPress={() => {
        router.push(`/products/${product.id}`);
      }}
    >
      <Text>{product.name}</Text>
    </Pressable>
  );
}

Cache Responses

export const revalidate = 3600; // 1 hour

export async function loader() {
  const data = await fetchData();
  return { data };
}

Next Steps

API Routes

Create server-side API endpoints

Typed Routes

Type-safe server routes

Deep Linking

Server-rendered deep links

Navigation

Client-side navigation