Learn how to build a modern mobile app with Expo Router, NativeWind (Tailwind CSS), and Strapi CMS. This step-by-step guide covers everything from project setup to implementing a blog with infinite scroll.
By the end of this tutorial, you'll have a fully functional React Native app that:
Tech Stack:
Building mobile apps traditionally meant learning separate languages for iOS (Swift) and Android (Kotlin/Java), doubling your development effort. React Native changes that by letting you write once in JavaScript/TypeScript and deploy to both platforms - plus the web.
Expo takes React Native further by removing the complexity of native tooling. No Xcode or Android Studio headaches for most development. Just write code and scan a QR code to see it running on your phone. It's the fastest way to go from idea to working app.
Strapi is an open-source headless CMS that gives you a complete backend without writing server code. You get:
Why this stack works well together:
| Concern | Solution | Benefit |
|---|---|---|
| Cross-platform UI | React Native + Expo | One codebase for iOS, Android, and web |
| Styling | NativeWind (Tailwind) | Familiar utility classes, rapid prototyping |
| Content management | Strapi | Non-developers can update content without code changes |
| Data fetching | React Query | Caching, background updates, offline support |
| Navigation | Expo Router | File-based routing |
This combination is ideal for content-driven apps like blogs, news apps, e-commerce, portfolios, or any app where content changes frequently and you want marketing teams to update it without developer involvement.
Before starting, make sure you have:
We'll use create-expo-app to scaffold a new Expo project:
npx create-expo-app@latestWhen prompted, name your app client. This creates a fully configured React Native project with TypeScript support.
Navigate to your project:
cd clientStart the Expo development server:
npx expo start --goThe --go flag ensures you're running in Expo Go mode. You'll see a QR code in your terminal.
Running on your phone: 1. Download "Expo Go" from the App Store (iOS) or Play Store (Android) 2. Scan the QR code with your camera (iOS) or Expo Go app (Android) 3. Make sure your phone and computer are on the same WiFi network
Tip: If connection fails, press
sin the terminal to switch to Tunnel mode.
NativeWind lets you use Tailwind CSS utility classes in React Native, making styling faster and more consistent.
# Runtime dependencies
npx expo install nativewind react-native-reanimated react-native-safe-area-context
# Dev dependencies
npm install --dev tailwindcss@^3.4.17 prettier-plugin-tailwindcss@^0.5.11Important: Use
npx expo installfor React Native packages - Expo automatically selects compatible versions.
npx expo customize metro.config.js
npx expo customize babel.config.js
npx tailwindcss init1/** @type {import('tailwindcss').Config} */
2module.exports = {
3 content: [
4 "./app/**/*.{js,jsx,ts,tsx}",
5 "./components/**/*.{js,jsx,ts,tsx}",
6 ],
7 presets: [require("nativewind/preset")],
8 theme: {
9 extend: {},
10 },
11 plugins: [],
12};1const { getDefaultConfig } = require("expo/metro-config");
2const { withNativeWind } = require("nativewind/metro");
3
4const config = getDefaultConfig(__dirname);
5
6module.exports = withNativeWind(config, { input: "./global.css" });1module.exports = function (api) {
2 api.cache(true);
3 return {
4 presets: [
5 ["babel-preset-expo", { jsxImportSource: "nativewind" }],
6 "nativewind/babel",
7 ],
8 };
9};Create a global.css file in your project root:
1@tailwind base;
2@tailwind components;
3@tailwind utilities;
4
5:root {
6 --primary: #0d6c9a;
7 --secondary: #f3cf36;
8 --tertiary: #1d6976;
9 --dark: #132134;
10}Create nativewind-env.d.ts in your project root:
1/// <reference types="nativewind/types" />Run the reset script to remove the boilerplate:
npm run reset-projectChoose n to delete the example files completely.
Update app/_layout.tsx:
1import "@/global.css";
2import { Stack } from "expo-router";
3import { SafeAreaProvider } from "react-native-safe-area-context";
4
5export default function RootLayout() {
6 return (
7 <SafeAreaProvider>
8 <Stack />
9 </SafeAreaProvider>
10 );
11}Update tailwind.config.js to use your CSS variables:
1/** @type {import('tailwindcss').Config} */
2module.exports = {
3 content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
4 presets: [require("nativewind/preset")],
5 theme: {
6 extend: {
7 colors: {
8 primary: "var(--primary)",
9 secondary: "var(--secondary)",
10 tertiary: "var(--tertiary)",
11 dark: "var(--dark)",
12 },
13 },
14 },
15 plugins: [],
16};Update app/index.tsx:
1import { Text, View } from "react-native";
2
3export default function Index() {
4 return (
5 <View className="flex-1 items-center justify-center gap-4 bg-dark p-8">
6 <Text className="text-2xl font-bold text-white">
7 NativeWind is working!
8 </Text>
9 <View className="w-full rounded-lg bg-primary p-4">
10 <Text className="text-center font-semibold text-white">
11 Primary Color
12 </Text>
13 </View>
14 </View>
15 );
16}Start the app to verify:
npx expo start --go --clearInstead of building from scratch, we'll use a pre-configured Strapi starter with sample data.
From your project root (not inside client):
git clone https://github.com/PaulBratslavsky/pauls-strapi-crashcourse.git serverYour folder structure should now be:
1your-project/
2├── client/ # React Native app
3└── server/ # Strapi backendcd server
npm install
cp .env.example .env npm run strapi -- import -f ./seed-data.tar.gzType Yes when prompted. This imports articles, authors, tags, and a landing page.
npm run developCreate your admin account when prompted, then navigate to Content Manager to see the seeded content.
Should already be enabled.
We'll use fetch to get data from our Strapi API, with the qs library for building query strings and React Query for caching.
In your client directory:
npm install qs @tanstack/react-query
npm install --save-dev @types/qsCreate lib/utils.ts:
1const STRAPI_URL = process.env.EXPO_PUBLIC_STRAPI_URL || "http://localhost:1337";
2
3export function getStrapiURL(): string {
4 return STRAPI_URL;
5}
6
7export function getStrapiMedia(url: string | null): string | null {
8 if (!url) return null;
9 if (url.startsWith("http")) return url;
10 return `${getStrapiURL()}${url}`;
11}For Physical Devices: Create a
.envfile with your computer's IP address:1EXPO_PUBLIC_STRAPI_URL=http://192.168.1.100:1337
We'll create a simple API helper using fetch and the qs library for query string formatting.
Create lib/strapi-api.ts:
1import qs from "qs";
2import { getStrapiURL } from "@/lib/utils";
3
4const BASE_URL = getStrapiURL();
5
6interface QueryOptions {
7 populate?: object | boolean;
8 filters?: object;
9 sort?: string[];
10 pagination?: {
11 page?: number;
12 pageSize?: number;
13 };
14 fields?: string[];
15}
16
17async function fetchAPI<T>(endpoint: string, options?: QueryOptions): Promise<T> {
18 const url = new URL(`/api/${endpoint}`, BASE_URL);
19 if (options) {
20 url.search = qs.stringify(options, { encode: false });
21 }
22
23 const response = await fetch(url.href, {
24 headers: {
25 "Content-Type": "application/json",
26 },
27 });
28
29 if (!response.ok) {
30 const text = await response.text();
31 console.error("API error response:", text);
32 throw new Error(`API error: ${response.status} ${response.statusText}`);
33 }
34
35 return response.json();
36}
37
38function single(singularApiId: string) {
39 return {
40 find: <T>(options?: QueryOptions) => fetchAPI<T>(singularApiId, options),
41 };
42}
43
44function collection(pluralApiId: string) {
45 return {
46 find: <T>(options?: QueryOptions) => fetchAPI<T>(pluralApiId, options),
47 };
48}
49
50export const strapiApi = {
51 single,
52 collection,
53};This helper provides:
strapiApi.single() - for fetching single-type content (like Landing Page)strapiApi.collection() - for fetching collection-type content (like Articles)Create types/index.ts:
1// Strapi Response Types
2export interface StrapiMeta {
3 pagination?: {
4 page: number;
5 pageSize: number;
6 pageCount: number;
7 total: number;
8 };
9}
10
11export interface StrapiResponse<T> {
12 data: T;
13 meta: StrapiMeta;
14}
15
16export interface StrapiCollectionResponse<T> {
17 data: T[];
18 meta: StrapiMeta;
19}
20
21// Media Types
22export interface TImage {
23 id: number;
24 documentId: string;
25 alternativeText: string | null;
26 url: string;
27 width?: number;
28 height?: number;
29}
30
31// Common Types
32export interface TLink {
33 id: number;
34 href: string;
35 label: string;
36 isExternal: boolean;
37 type: string | null;
38}
39
40export interface TCard {
41 id: number;
42 heading: string;
43 text: string;
44}
45
46export interface TTag {
47 id: number;
48 documentId: string;
49 title: string;
50}
51
52export interface TAuthor {
53 id: number;
54 documentId: string;
55 fullName: string;
56 bio?: string;
57 image?: TImage;
58}
59
60// Block Types
61export interface IHero {
62 __component: "blocks.hero";
63 id: number;
64 subHeading?: string;
65 heading: string;
66 highlightedText?: string;
67 text: string;
68 links: TLink[];
69 image: TImage;
70}
71
72export interface ISectionHeading {
73 __component: "blocks.section-heading";
74 id: number;
75 subHeading: string;
76 heading: string;
77}
78
79export type TCardGridItem = TCard & {
80 icon?: TImage;
81};
82
83export interface ICardGrid {
84 __component: "blocks.card-grid";
85 id: number;
86 subHeading?: string;
87 heading?: string;
88 cards: TCardGridItem[];
89}
90
91export interface IContentWithImage {
92 __component: "blocks.content-with-image";
93 id: number;
94 reversed: boolean;
95 heading: string;
96 subHeading?: string;
97 content: string;
98 link?: TLink;
99 image: TImage;
100}
101
102export interface IMarkdownText {
103 __component: "blocks.markdown";
104 id: number;
105 content: string;
106}
107
108export interface IFaqItem {
109 id: number;
110 heading: string;
111 text: string;
112}
113
114export interface IFaqs {
115 __component: "blocks.faqs";
116 id: number;
117 heading?: string;
118 subHeading?: string;
119 faq: IFaqItem[];
120}
121
122export type Block =
123 | IHero
124 | ISectionHeading
125 | ICardGrid
126 | IContentWithImage
127 | IMarkdownText
128 | IFaqs;
129
130// Page Types
131export interface LandingPage {
132 id: number;
133 documentId: string;
134 title: string;
135 description: string;
136 createdAt: string;
137 updatedAt: string;
138 blocks: Block[];
139}
140
141// Article Types
142export interface Article {
143 id: number;
144 documentId: string;
145 title: string;
146 slug: string;
147 description: string;
148 content: string;
149 createdAt: string;
150 updatedAt: string;
151 publishedAt: string;
152 featuredImage?: TImage;
153 author?: TAuthor;
154 contentTags?: TTag[];
155}Create data/loaders.ts:
1import { strapiApi } from "@/lib/strapi-api";
2import type {
3 LandingPage,
4 Article,
5 StrapiResponse,
6 StrapiCollectionResponse,
7} from "@/types";
8
9const PAGE_SIZE = 10;
10
11// Landing Page (Single Type)
12export async function getLandingPageData(): Promise<StrapiResponse<LandingPage>> {
13 const response = await strapiApi.single("landing-page").find({
14 populate: {
15 blocks: {
16 populate: {
17 image: { fields: ["url", "alternativeText", "width", "height"] },
18 link: { fields: ["url", "text", "isExternal"] },
19 feature: { fields: ["heading", "subHeading", "icon"] },
20 },
21 },
22 },
23 });
24 return response as StrapiResponse<LandingPage>;
25}
26
27// Articles (Collection Type)
28const articles = strapiApi.collection("articles");
29
30export interface GetArticlesParams {
31 page?: number;
32 pageSize?: number;
33 tag?: string;
34}
35
36export async function getArticles(
37 params?: GetArticlesParams
38): Promise<StrapiCollectionResponse<Article>> {
39 const { page = 1, pageSize = PAGE_SIZE, tag } = params || {};
40
41 const filters = tag
42 ? {
43 contentTags: {
44 title: { $containsi: tag },
45 },
46 }
47 : undefined;
48
49 const response = await articles.find({
50 sort: ["createdAt:desc"],
51 pagination: {
52 page,
53 pageSize,
54 },
55 filters,
56 });
57
58 return response as StrapiCollectionResponse<Article>;
59}
60
61export async function getArticleBySlug(
62 slug: string
63): Promise<StrapiCollectionResponse<Article>> {
64 const response = await articles.find({
65 filters: {
66 slug: { $eq: slug },
67 },
68 });
69
70 return response as StrapiCollectionResponse<Article>;
71}Create hooks/useLandingPage.ts:
1import { useQuery } from "@tanstack/react-query";
2import { getLandingPageData } from "@/data/loaders";
3
4export function useLandingPage() {
5 return useQuery({
6 queryKey: ["landing-page"],
7 queryFn: getLandingPageData,
8 });
9}Update app/_layout.tsx:
1import "@/global.css";
2import { Stack } from "expo-router";
3import { SafeAreaProvider } from "react-native-safe-area-context";
4import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
6const queryClient = new QueryClient({
7 defaultOptions: {
8 queries: {
9 staleTime: 1000 * 60 * 5, // 5 minutes
10 retry: 3,
11 },
12 },
13});
14
15export default function RootLayout() {
16 return (
17 <QueryClientProvider client={queryClient}>
18 <SafeAreaProvider>
19 <Stack />
20 </SafeAreaProvider>
21 </QueryClientProvider>
22 );
23}Before building the UI components, let's verify our data fetching is working by displaying the raw JSON response. This is an important step to confirm the API connection works.
Update app/index.tsx to display the raw data:
1import { Text, View, ActivityIndicator, ScrollView } from "react-native";
2import { useLandingPage } from "@/hooks/useLandingPage";
3
4export default function Index() {
5 const { data, isLoading, error } = useLandingPage();
6
7 if (isLoading) {
8 return (
9 <View className="flex-1 items-center justify-center bg-[#F9F5F2]">
10 <ActivityIndicator size="large" color="#c4a1ff" />
11 <Text className="mt-4 text-gray-600">Loading...</Text>
12 </View>
13 );
14 }
15
16 if (error) {
17 return (
18 <View className="flex-1 items-center justify-center bg-[#F9F5F2] p-8">
19 <Text className="text-xl font-bold text-red-500">Error</Text>
20 <Text className="mt-2 text-center text-gray-700">
21 {error.message || "Failed to load landing page"}
22 </Text>
23 <Text className="mt-4 text-sm text-gray-500">
24 Make sure Strapi is running at http://localhost:1337
25 </Text>
26 </View>
27 );
28 }
29
30 // Display raw JSON to verify data is coming through
31 return (
32 <ScrollView className="flex-1 bg-[#F9F5F2] p-4">
33 <Text className="text-xl font-bold mb-4 text-gray-900">
34 Data Fetching Works!
35 </Text>
36 <Text className="text-sm text-gray-600 mb-2">
37 Raw JSON response from Strapi:
38 </Text>
39 <View className="bg-gray-100 p-4 rounded-lg">
40 <Text className="text-xs font-mono text-gray-800">
41 {JSON.stringify(data, null, 2)}
42 </Text>
43 </View>
44 </ScrollView>
45 );
46}Run the app:
npx expo start --go --clearYou should see the raw JSON data from your Strapi landing page displayed on screen. This confirms:
Test the API by making a GET request to http://localhost:1337/api/landing-page.
If you see an error, check:
npm run develop in the server folder)Now that we've confirmed data fetching works, let's build the UI progressively. We'll start with the Hero component, test it, then add the remaining blocks.
npx expo install expo-imageCreate components/strapi-image.tsx:
1import { Image, ImageProps } from "expo-image";
2import { getStrapiMedia } from "@/lib/utils";
3
4interface StrapiImageProps extends Omit<ImageProps, "source"> {
5 src: string;
6 alt?: string;
7}
8
9export function StrapiImage({ src, alt, ...props }: StrapiImageProps) {
10 const imageUrl = getStrapiMedia(src);
11
12 if (!imageUrl) return null;
13
14 return (
15 <Image
16 source={{ uri: imageUrl }}
17 accessibilityLabel={alt}
18 placeholder={{ blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4" }}
19 transition={200}
20 {...props}
21 />
22 );
23}Important: Unlike web, you must use explicit
styledimensions (notclassName) for images to display on iOS/Android.
Let's start with just the Hero block. Create components/blocks/hero.tsx:
1import { View, Text, Pressable, Linking } from "react-native";
2import { Ionicons } from "@expo/vector-icons";
3import { StrapiImage } from "@/components/strapi-image";
4import type { IHero } from "@/types";
5
6export function Hero({
7 subHeading,
8 heading,
9 highlightedText,
10 text,
11 links,
12 image,
13}: Readonly<IHero>) {
14 const handlePress = (href: string, isExternal: boolean) => {
15 if (isExternal) {
16 Linking.openURL(href);
17 }
18 };
19
20 return (
21 <View className="bg-[#F9F5F2] py-12 px-4">
22 <View className="w-full max-w-6xl mx-auto">
23 {/* Image */}
24 <View className="w-full mb-8">
25 <StrapiImage
26 src={image.url}
27 alt={image.alternativeText || heading}
28 style={{ width: "100%", height: 192, borderRadius: 8 }}
29 contentFit="cover"
30 />
31 </View>
32
33 {/* Content */}
34 <View className="items-center">
35 {subHeading && (
36 <Text className="text-base text-gray-600 mb-2">{subHeading}</Text>
37 )}
38
39 <Text className="text-3xl font-bold text-center text-gray-900">
40 {heading}
41 </Text>
42
43 {highlightedText && (
44 <View className="bg-[#e7f192] px-3 py-1 mt-3 rotate-2 border-2 border-black">
45 <Text className="text-2xl font-bold">{highlightedText}</Text>
46 </View>
47 )}
48
49 <Text className="text-base text-gray-600 text-center mt-4 px-4">
50 {text}
51 </Text>
52
53 {links.length > 0 && (
54 <View className="flex-row flex-wrap justify-center gap-3 mt-6">
55 {links.map((link, index) => (
56 <Pressable
57 key={`link-${link.id}-${index}`}
58 onPress={() => handlePress(link.href, link.isExternal)}
59 className={`flex-row items-center px-6 py-3 rounded-lg border-2 border-black ${
60 index === 0 ? "bg-[#c4a1ff]" : "bg-white"
61 }`}
62 >
63 <Text className="font-semibold text-base">{link.label}</Text>
64 {index === 0 && (
65 <Ionicons
66 name="arrow-forward"
67 size={18}
68 color="black"
69 style={{ marginLeft: 8 }}
70 />
71 )}
72 </Pressable>
73 ))}
74 </View>
75 )}
76 </View>
77 </View>
78 </View>
79 );
80}Create components/blocks/block-renderer.tsx:
1import { View, Text } from "react-native";
2import { Hero } from "./hero";
3import type { Block } from "@/types";
4
5interface BlockRendererProps {
6 blocks: Block[];
7}
8
9export function BlockRenderer({ blocks }: Readonly<BlockRendererProps>) {
10 const renderBlock = (block: Block) => {
11 switch (block.__component) {
12 case "blocks.hero":
13 return <Hero {...block} />;
14 default:
15 // Show a placeholder for unimplemented blocks
16 return (
17 <View className="p-4 m-4 bg-yellow-100 border border-yellow-400 rounded">
18 <Text className="text-yellow-800">
19 Block not implemented: {block.__component}
20 </Text>
21 </View>
22 );
23 }
24 };
25
26 return (
27 <View>
28 {blocks.map((block, index) => (
29 <View key={`${block.__component}-${block.id}-${index}`}>
30 {renderBlock(block)}
31 </View>
32 ))}
33 </View>
34 );
35}Create components/blocks/index.ts:
1export { BlockRenderer } from "./block-renderer";
2export { Hero } from "./hero";Now update app/index.tsx to render the blocks:
1import { Text, View, ActivityIndicator, ScrollView } from "react-native";
2import { useLandingPage } from "@/hooks/useLandingPage";
3import { BlockRenderer } from "@/components/blocks";
4import type { Block } from "@/types";
5
6export default function Index() {
7 const { data, isLoading, error } = useLandingPage();
8
9 if (isLoading) {
10 return (
11 <View className="flex-1 items-center justify-center bg-[#F9F5F2]">
12 <ActivityIndicator size="large" color="#c4a1ff" />
13 <Text className="mt-4 text-gray-600">Loading...</Text>
14 </View>
15 );
16 }
17
18 if (error) {
19 return (
20 <View className="flex-1 items-center justify-center bg-[#F9F5F2] p-8">
21 <Text className="text-xl font-bold text-red-500">Error</Text>
22 <Text className="mt-2 text-center text-gray-700">
23 {error.message || "Failed to load landing page"}
24 </Text>
25 </View>
26 );
27 }
28
29 const landingPage = data?.data;
30 const blocks = (landingPage?.blocks || []) as Block[];
31
32 return (
33 <ScrollView className="flex-1 bg-[#F9F5F2]">
34 <BlockRenderer blocks={blocks} />
35 </ScrollView>
36 );
37}Run the app now. You should see:
Now let's add all the other block components.
Create components/blocks/section-heading.tsx:
1import { View, Text } from "react-native";
2import type { ISectionHeading } from "@/types";
3
4export function SectionHeading({
5 subHeading,
6 heading,
7}: Readonly<ISectionHeading>) {
8 return (
9 <View className="bg-[#F9F5F2] pt-12 pb-6 px-4">
10 <View className="w-full max-w-6xl mx-auto">
11 <Text className="text-lg text-gray-600 mb-2">{subHeading}</Text>
12 <Text className="text-2xl font-bold text-gray-900">{heading}</Text>
13 </View>
14 </View>
15 );
16}Create components/blocks/card-grid.tsx:
1import { View, Text, ScrollView } from "react-native";
2import { Ionicons } from "@expo/vector-icons";
3import { StrapiImage } from "@/components/strapi-image";
4import type { ICardGrid } from "@/types";
5
6const cardVariants = [
7 "bg-[#C4A1FF]",
8 "bg-[#E7F193]",
9 "bg-[#C4FF83]",
10 "bg-[#FFB3BA]",
11 "bg-[#A1D4FF]",
12 "bg-[#FFDAA1]",
13] as const;
14
15const fallbackIcons = [
16 "star",
17 "heart",
18 "flash",
19 "rocket",
20 "bulb",
21 "diamond",
22] as const;
23
24function getCardVariant(index: number) {
25 return cardVariants[index % cardVariants.length];
26}
27
28function getFallbackIcon(index: number) {
29 return fallbackIcons[index % fallbackIcons.length];
30}
31
32export function CardGrid({
33 subHeading,
34 heading,
35 cards,
36}: Readonly<ICardGrid>) {
37 return (
38 <View className="bg-[#F9F5F2] py-12">
39 {(subHeading || heading) && (
40 <View className="px-4 mb-6">
41 <View className="w-full max-w-6xl mx-auto">
42 {subHeading && (
43 <Text className="text-lg text-gray-600 mb-2">{subHeading}</Text>
44 )}
45 {heading && (
46 <Text className="text-2xl font-bold text-gray-900">{heading}</Text>
47 )}
48 </View>
49 </View>
50 )}
51
52 <ScrollView
53 horizontal
54 showsHorizontalScrollIndicator={false}
55 contentContainerClassName="px-4 gap-4"
56 >
57 {cards.map((card, index) => (
58 <View
59 key={`card-${card.id}-${index}`}
60 className="w-64 p-5 bg-white border-2 border-black rounded-lg"
61 >
62 <View
63 className={`w-14 h-14 rounded-full border-2 border-black items-center justify-center mb-4 ${getCardVariant(index)}`}
64 >
65 {card.icon ? (
66 <StrapiImage
67 src={card.icon.url}
68 alt={card.icon.alternativeText || card.heading}
69 style={{ width: 32, height: 32 }}
70 contentFit="contain"
71 />
72 ) : (
73 <Ionicons
74 name={getFallbackIcon(index)}
75 size={24}
76 color="black"
77 />
78 )}
79 </View>
80
81 <Text className="text-lg font-bold text-gray-900 mb-2">
82 {card.heading}
83 </Text>
84 <Text className="text-sm text-gray-600 leading-relaxed">
85 {card.text}
86 </Text>
87 </View>
88 ))}
89 </ScrollView>
90 </View>
91 );
92}Create components/blocks/content-with-image.tsx:
1import { View, Text, Pressable, Linking } from "react-native";
2import { StrapiImage } from "@/components/strapi-image";
3import type { IContentWithImage } from "@/types";
4
5export function ContentWithImage({
6 reversed,
7 heading,
8 subHeading,
9 content,
10 link,
11 image,
12}: Readonly<IContentWithImage>) {
13 const handlePress = () => {
14 if (link?.href) {
15 if (link.isExternal) {
16 Linking.openURL(link.href);
17 }
18 }
19 };
20
21 return (
22 <View className="bg-white py-12 px-4">
23 <View className="w-full max-w-6xl mx-auto">
24 <View className="mb-8">
25 <View className="relative">
26 <View className="absolute inset-0 bg-[#c4a1ff] translate-x-2 translate-y-2 rounded-lg" />
27 <View className="relative border-2 border-black rounded-lg overflow-hidden bg-white">
28 <StrapiImage
29 src={image.url}
30 alt={image.alternativeText || heading}
31 style={{ width: "100%", height: 192 }}
32 contentFit="cover"
33 />
34 </View>
35 </View>
36 </View>
37
38 <View>
39 {subHeading && (
40 <View className="bg-[#e7f192] self-start px-3 py-1 mb-4 border border-black rounded">
41 <Text className="text-sm font-medium">{subHeading}</Text>
42 </View>
43 )}
44
45 <Text className="text-2xl font-bold text-gray-900 mb-4">
46 {heading}
47 </Text>
48
49 <Text className="text-base text-gray-600 leading-relaxed mb-6">
50 {content}
51 </Text>
52
53 {link?.href && link.label && (
54 <Pressable
55 onPress={handlePress}
56 className="self-start bg-black px-6 py-3 rounded-lg"
57 >
58 <Text className="text-white font-semibold">
59 {link.label} →
60 </Text>
61 </Pressable>
62 )}
63 </View>
64 </View>
65 </View>
66 );
67}Install markdown dependencies:
npm install react-native-markdown-displayCreate components/blocks/markdown-text.tsx:
1import { View, StyleSheet } from "react-native";
2import Markdown from "react-native-markdown-display";
3import type { IMarkdownText } from "@/types";
4
5const markdownStyles = StyleSheet.create({
6 body: { fontSize: 16, lineHeight: 24, color: "#374151" },
7 heading1: { fontSize: 32, fontWeight: "bold", color: "#111827", marginBottom: 16, marginTop: 24 },
8 heading2: { fontSize: 24, fontWeight: "bold", color: "#111827", marginBottom: 12, marginTop: 20 },
9 heading3: { fontSize: 20, fontWeight: "bold", color: "#111827", marginBottom: 8, marginTop: 16 },
10 paragraph: { fontSize: 16, lineHeight: 26, color: "#4B5563", marginBottom: 16 },
11 link: { color: "#7C3AED", textDecorationLine: "underline" },
12 blockquote: {
13 backgroundColor: "#F3F4F6",
14 borderLeftWidth: 4,
15 borderLeftColor: "#7C3AED",
16 paddingLeft: 16,
17 paddingVertical: 8,
18 marginVertical: 16,
19 fontStyle: "italic",
20 },
21 fence: {
22 backgroundColor: "#1F2937",
23 borderRadius: 8,
24 padding: 16,
25 marginVertical: 16,
26 color: "#F9FAFB",
27 fontFamily: "monospace",
28 fontSize: 14,
29 },
30});
31
32export function MarkdownText({ content }: Readonly<IMarkdownText>) {
33 return (
34 <View className="py-12 px-4">
35 <View className="w-full max-w-4xl mx-auto">
36 <Markdown style={markdownStyles}>{content}</Markdown>
37 </View>
38 </View>
39 );
40}Create components/blocks/faqs.tsx:
1import { useState } from "react";
2import { View, Text, Pressable } from "react-native";
3import { Ionicons } from "@expo/vector-icons";
4import type { IFaqs } from "@/types";
5
6export function Faqs({ heading, subHeading, faq }: Readonly<IFaqs>) {
7 const [openIndex, setOpenIndex] = useState<number | null>(null);
8
9 const toggleFAQ = (index: number) => {
10 setOpenIndex(openIndex === index ? null : index);
11 };
12
13 return (
14 <View className="w-full py-12 bg-[#f5f3e8] px-4">
15 <View className="items-center mb-8">
16 <Text className="text-2xl font-bold text-gray-900 text-center mb-2">
17 {heading || "Frequently Asked Questions"}
18 </Text>
19 {subHeading && (
20 <Text className="text-base text-gray-600 text-center max-w-lg">
21 {subHeading}
22 </Text>
23 )}
24 </View>
25
26 <View className="w-full max-w-3xl mx-auto">
27 {faq.map((item, index) => (
28 <View
29 key={`faq-${item.id}-${index}`}
30 className={`mb-4 border-2 border-black bg-white rounded-lg overflow-hidden ${
31 openIndex === index ? "shadow-lg" : "shadow-md"
32 }`}
33 >
34 <Pressable
35 onPress={() => toggleFAQ(index)}
36 className="flex-row justify-between items-center p-4"
37 >
38 <Text className="flex-1 font-bold text-base text-gray-900 pr-4">
39 {item.heading}
40 </Text>
41 <Ionicons
42 name={openIndex === index ? "chevron-up" : "chevron-down"}
43 size={24}
44 color="black"
45 />
46 </Pressable>
47
48 {openIndex === index && (
49 <View className="px-4 pb-4 border-t border-dashed border-gray-300">
50 <Text className="text-base text-gray-600 pt-4 leading-relaxed">
51 {item.text}
52 </Text>
53 </View>
54 )}
55 </View>
56 ))}
57 </View>
58 </View>
59 );
60}Update components/blocks/block-renderer.tsx:
1import { View } from "react-native";
2import { Hero } from "./hero";
3import { SectionHeading } from "./section-heading";
4import { CardGrid } from "./card-grid";
5import { ContentWithImage } from "./content-with-image";
6import { MarkdownText } from "./markdown-text";
7import { Faqs } from "./faqs";
8import type { Block } from "@/types";
9
10interface BlockRendererProps {
11 blocks: Block[];
12}
13
14export function BlockRenderer({ blocks }: Readonly<BlockRendererProps>) {
15 const renderBlock = (block: Block) => {
16 switch (block.__component) {
17 case "blocks.hero":
18 return <Hero {...block} />;
19 case "blocks.section-heading":
20 return <SectionHeading {...block} />;
21 case "blocks.card-grid":
22 return <CardGrid {...block} />;
23 case "blocks.content-with-image":
24 return <ContentWithImage {...block} />;
25 case "blocks.markdown":
26 return <MarkdownText {...block} />;
27 case "blocks.faqs":
28 return <Faqs {...block} />;
29 default:
30 return null;
31 }
32 };
33
34 return (
35 <View>
36 {blocks.map((block, index) => (
37 <View key={`${block.__component}-${block.id}-${index}`}>
38 {renderBlock(block)}
39 </View>
40 ))}
41 </View>
42 );
43}Update components/blocks/index.ts:
1export { BlockRenderer } from "./block-renderer";
2export { Hero } from "./hero";
3export { SectionHeading } from "./section-heading";
4export { CardGrid } from "./card-grid";
5export { ContentWithImage } from "./content-with-image";
6export { MarkdownText } from "./markdown-text";
7export { Faqs } from "./faqs";Run the app again. You should now see the complete landing page with all blocks rendering properly!
Now let's add a second screen to display blog articles with tab navigation.
Create hooks/useArticles.ts:
1import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
2import { getArticles, getArticleBySlug } from "@/data/loaders";
3
4export function useArticles(params?: { page?: number; tag?: string }) {
5 return useQuery({
6 queryKey: ["articles", params],
7 queryFn: () => getArticles(params),
8 });
9}
10
11export function useInfiniteArticles(tag?: string) {
12 return useInfiniteQuery({
13 queryKey: ["articles", "infinite", tag],
14 queryFn: ({ pageParam = 1 }) => getArticles({ page: pageParam, tag }),
15 initialPageParam: 1,
16 getNextPageParam: (lastPage) => {
17 const { page, pageCount } = lastPage.meta.pagination || { page: 1, pageCount: 1 };
18 return page < pageCount ? page + 1 : undefined;
19 },
20 });
21}
22
23export function useArticleBySlug(slug: string) {
24 return useQuery({
25 queryKey: ["article", slug],
26 queryFn: () => getArticleBySlug(slug),
27 enabled: !!slug,
28 });
29}Create the tabs folder and layout. First, create app/(tabs)/_layout.tsx:
1import { Tabs } from "expo-router";
2import { Ionicons } from "@expo/vector-icons";
3
4export default function TabLayout() {
5 return (
6 <Tabs
7 screenOptions={{
8 tabBarActiveTintColor: "#c4a1ff",
9 tabBarInactiveTintColor: "#6B7280",
10 tabBarStyle: {
11 backgroundColor: "#F9F5F2",
12 borderTopColor: "#E5E7EB",
13 borderTopWidth: 1,
14 },
15 headerStyle: {
16 backgroundColor: "#F9F5F2",
17 },
18 headerTintColor: "#111827",
19 headerTitleStyle: {
20 fontWeight: "bold",
21 },
22 }}
23 >
24 <Tabs.Screen
25 name="index"
26 options={{
27 title: "Home",
28 tabBarIcon: ({ color, size }) => (
29 <Ionicons name="home" size={size} color={color} />
30 ),
31 }}
32 />
33 <Tabs.Screen
34 name="blog"
35 options={{
36 title: "Blog",
37 tabBarIcon: ({ color, size }) => (
38 <Ionicons name="newspaper" size={size} color={color} />
39 ),
40 }}
41 />
42 </Tabs>
43 );
44}Move your index screen to app/(tabs)/index.tsx:
1import { Text, View, ActivityIndicator, ScrollView } from "react-native";
2import { useLandingPage } from "@/hooks/useLandingPage";
3import { BlockRenderer } from "@/components/blocks";
4import type { Block } from "@/types";
5
6export default function HomeScreen() {
7 const { data, isLoading, error } = useLandingPage();
8
9 if (isLoading) {
10 return (
11 <View className="flex-1 items-center justify-center bg-[#F9F5F2]">
12 <ActivityIndicator size="large" color="#c4a1ff" />
13 <Text className="mt-4 text-gray-600">Loading...</Text>
14 </View>
15 );
16 }
17
18 if (error) {
19 return (
20 <View className="flex-1 items-center justify-center bg-[#F9F5F2] p-8">
21 <Text className="text-xl font-bold text-red-500">Error</Text>
22 <Text className="mt-2 text-center text-gray-700">
23 {error.message || "Failed to load landing page"}
24 </Text>
25 </View>
26 );
27 }
28
29 const landingPage = data?.data;
30 const blocks = (landingPage?.blocks || []) as Block[];
31
32 return (
33 <ScrollView className="flex-1 bg-[#F9F5F2]">
34 <BlockRenderer blocks={blocks} />
35 </ScrollView>
36 );
37}Delete the old app/index.tsx file if it exists.
Create components/article-card.tsx:
1import { View, Text, Pressable } from "react-native";
2import { useRouter, Href } from "expo-router";
3import { StrapiImage } from "@/components/strapi-image";
4import type { Article } from "@/types";
5
6interface ArticleCardProps {
7 article: Article;
8}
9
10export function ArticleCard({ article }: ArticleCardProps) {
11 const router = useRouter();
12
13 const handlePress = () => {
14 router.push(`/article/${article.slug}` as Href);
15 };
16
17 const formattedDate = new Date(article.publishedAt).toLocaleDateString("en-US", {
18 year: "numeric",
19 month: "short",
20 day: "numeric",
21 });
22
23 return (
24 <Pressable
25 onPress={handlePress}
26 className="bg-white border-2 border-black rounded-lg overflow-hidden mb-4 active:opacity-80"
27 >
28 {article.featuredImage && (
29 <StrapiImage
30 src={article.featuredImage.url}
31 alt={article.featuredImage.alternativeText || article.title}
32 style={{ width: "100%", height: 160 }}
33 contentFit="cover"
34 />
35 )}
36
37 <View className="p-4">
38 {article.contentTags && article.contentTags.length > 0 && (
39 <View className="flex-row flex-wrap gap-2 mb-2">
40 {article.contentTags.slice(0, 3).map((tag) => (
41 <View
42 key={tag.id}
43 className="bg-[#e7f192] px-2 py-1 rounded border border-black"
44 >
45 <Text className="text-xs font-medium">{tag.title}</Text>
46 </View>
47 ))}
48 </View>
49 )}
50
51 <Text className="text-lg font-bold text-gray-900 mb-2" numberOfLines={2}>
52 {article.title}
53 </Text>
54
55 <Text className="text-sm text-gray-600 mb-3" numberOfLines={2}>
56 {article.description}
57 </Text>
58
59 <View className="flex-row items-center justify-between">
60 {article.author && (
61 <Text className="text-xs text-gray-500">
62 By {article.author.fullName}
63 </Text>
64 )}
65 <Text className="text-xs text-gray-400">{formattedDate}</Text>
66 </View>
67 </View>
68 </Pressable>
69 );
70}Create app/(tabs)/blog.tsx:
1import { Text, View, ActivityIndicator, FlatList, RefreshControl } from "react-native";
2import { useState, useCallback, useMemo } from "react";
3import { useInfiniteArticles } from "@/hooks/useArticles";
4import { ArticleCard } from "@/components/article-card";
5import type { Article } from "@/types";
6
7export default function BlogScreen() {
8 const [refreshing, setRefreshing] = useState(false);
9 const {
10 data,
11 isLoading,
12 error,
13 refetch,
14 fetchNextPage,
15 hasNextPage,
16 isFetchingNextPage,
17 } = useInfiniteArticles();
18
19 const onRefresh = useCallback(async () => {
20 setRefreshing(true);
21 await refetch();
22 setRefreshing(false);
23 }, [refetch]);
24
25 const onEndReached = useCallback(() => {
26 if (hasNextPage && !isFetchingNextPage) {
27 fetchNextPage();
28 }
29 }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
30
31 const articles = useMemo(() => {
32 return data?.pages.flatMap((page) => page.data) || [];
33 }, [data]);
34
35 const totalCount = data?.pages[0]?.meta?.pagination?.total || 0;
36
37 if (isLoading && !refreshing) {
38 return (
39 <View className="flex-1 items-center justify-center bg-[#F9F5F2]">
40 <ActivityIndicator size="large" color="#c4a1ff" />
41 <Text className="mt-4 text-gray-600">Loading articles...</Text>
42 </View>
43 );
44 }
45
46 if (error) {
47 return (
48 <View className="flex-1 items-center justify-center bg-[#F9F5F2] p-8">
49 <Text className="text-xl font-bold text-red-500">Error</Text>
50 <Text className="mt-2 text-center text-gray-700">
51 {error.message || "Failed to load articles"}
52 </Text>
53 </View>
54 );
55 }
56
57 const renderItem = ({ item }: { item: Article }) => (
58 <ArticleCard article={item} />
59 );
60
61 const renderHeader = () => (
62 <View className="mb-4">
63 <Text className="text-2xl font-bold text-gray-900">Latest Articles</Text>
64 <Text className="text-gray-600 mt-1">
65 {totalCount} article{totalCount !== 1 ? "s" : ""}
66 </Text>
67 </View>
68 );
69
70 const renderFooter = () => {
71 if (!isFetchingNextPage) return null;
72 return (
73 <View className="py-4">
74 <ActivityIndicator size="small" color="#c4a1ff" />
75 </View>
76 );
77 };
78
79 return (
80 <View className="flex-1 bg-[#F9F5F2]">
81 <FlatList
82 data={articles}
83 renderItem={renderItem}
84 keyExtractor={(item) => item.documentId}
85 contentContainerStyle={{ padding: 16 }}
86 ListHeaderComponent={renderHeader}
87 ListFooterComponent={renderFooter}
88 refreshControl={
89 <RefreshControl
90 refreshing={refreshing}
91 onRefresh={onRefresh}
92 tintColor="#c4a1ff"
93 />
94 }
95 onEndReached={onEndReached}
96 onEndReachedThreshold={0.5}
97 showsVerticalScrollIndicator={false}
98 />
99 </View>
100 );
101}Run the app now. You should see:
Finally, let's create the article detail page to view full articles.
Create app/article/[slug].tsx:
1import { Text, View, ActivityIndicator, ScrollView, StyleSheet } from "react-native";
2import { useLocalSearchParams, Stack } from "expo-router";
3import Markdown from "react-native-markdown-display";
4import { useArticleBySlug } from "@/hooks/useArticles";
5import { StrapiImage } from "@/components/strapi-image";
6
7const markdownStyles = StyleSheet.create({
8 body: { fontSize: 16, lineHeight: 26, color: "#374151" },
9 heading1: { fontSize: 28, fontWeight: "bold", color: "#111827", marginBottom: 16, marginTop: 24 },
10 heading2: { fontSize: 22, fontWeight: "bold", color: "#111827", marginBottom: 12, marginTop: 20 },
11 paragraph: { fontSize: 16, lineHeight: 26, color: "#4B5563", marginBottom: 16 },
12 link: { color: "#7C3AED", textDecorationLine: "underline" },
13 blockquote: {
14 backgroundColor: "#F3F4F6",
15 borderLeftWidth: 4,
16 borderLeftColor: "#c4a1ff",
17 paddingLeft: 16,
18 paddingVertical: 8,
19 marginVertical: 16,
20 fontStyle: "italic",
21 },
22 fence: {
23 backgroundColor: "#1F2937",
24 borderRadius: 8,
25 padding: 16,
26 marginVertical: 16,
27 color: "#F9FAFB",
28 fontFamily: "monospace",
29 fontSize: 14,
30 },
31});
32
33export default function ArticleDetailScreen() {
34 const { slug } = useLocalSearchParams<{ slug: string }>();
35 const { data, isLoading, error } = useArticleBySlug(slug || "");
36
37 if (isLoading) {
38 return (
39 <View className="flex-1 items-center justify-center bg-[#F9F5F2]">
40 <ActivityIndicator size="large" color="#c4a1ff" />
41 <Text className="mt-4 text-gray-600">Loading article...</Text>
42 </View>
43 );
44 }
45
46 if (error || !data?.data?.[0]) {
47 return (
48 <View className="flex-1 items-center justify-center bg-[#F9F5F2] p-8">
49 <Text className="text-xl font-bold text-red-500">Error</Text>
50 <Text className="mt-2 text-center text-gray-700">
51 {error?.message || "Article not found"}
52 </Text>
53 </View>
54 );
55 }
56
57 const article = data.data[0];
58
59 const formattedDate = new Date(article.publishedAt).toLocaleDateString("en-US", {
60 year: "numeric",
61 month: "long",
62 day: "numeric",
63 });
64
65 return (
66 <>
67 <Stack.Screen
68 options={{
69 title: "",
70 headerStyle: { backgroundColor: "#F9F5F2" },
71 headerTintColor: "#111827",
72 }}
73 />
74 <ScrollView className="flex-1 bg-[#F9F5F2]">
75 {article.featuredImage && (
76 <StrapiImage
77 src={article.featuredImage.url}
78 alt={article.featuredImage.alternativeText || article.title}
79 style={{ width: "100%", height: 220 }}
80 contentFit="cover"
81 />
82 )}
83
84 <View className="p-4">
85 {article.contentTags && article.contentTags.length > 0 && (
86 <View className="flex-row flex-wrap gap-2 mb-3">
87 {article.contentTags.map((tag) => (
88 <View
89 key={tag.id}
90 className="bg-[#e7f192] px-3 py-1 rounded border border-black"
91 >
92 <Text className="text-xs font-medium">{tag.title}</Text>
93 </View>
94 ))}
95 </View>
96 )}
97
98 <Text className="text-2xl font-bold text-gray-900 mb-2">
99 {article.title}
100 </Text>
101
102 <View className="flex-row items-center mb-4">
103 {article.author && (
104 <Text className="text-sm text-gray-600">
105 By {article.author.fullName}
106 </Text>
107 )}
108 <Text className="text-sm text-gray-400 mx-2">•</Text>
109 <Text className="text-sm text-gray-400">{formattedDate}</Text>
110 </View>
111
112 <Text className="text-base text-gray-600 mb-6 leading-relaxed">
113 {article.description}
114 </Text>
115
116 <View className="border-t border-gray-200 pt-6">
117 <Markdown style={markdownStyles}>{article.content}</Markdown>
118 </View>
119 </View>
120 </ScrollView>
121 </>
122 );
123}Update app/_layout.tsx to configure the stack navigation:
1import "@/global.css";
2import { Stack } from "expo-router";
3import { SafeAreaProvider } from "react-native-safe-area-context";
4import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
6const queryClient = new QueryClient({
7 defaultOptions: {
8 queries: {
9 staleTime: 1000 * 60 * 5,
10 retry: 3,
11 },
12 },
13});
14
15export default function RootLayout() {
16 return (
17 <QueryClientProvider client={queryClient}>
18 <SafeAreaProvider>
19 <Stack>
20 <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
21 <Stack.Screen
22 name="article/[slug]"
23 options={{
24 headerBackTitle: "Back",
25 headerStyle: { backgroundColor: "#F9F5F2" },
26 headerTintColor: "#111827",
27 }}
28 />
29 </Stack>
30 </SafeAreaProvider>
31 </QueryClientProvider>
32 );
33}Open two terminal windows:
Terminal 1 - Strapi:
cd server
npm run developTerminal 2 - Expo:
cd client
npx expo start --go --clearUse explicit style dimensions:
1// Wrong
2<StrapiImage className="w-full h-48" ... />
3
4// Correct
5<StrapiImage style={{ width: "100%", height: 192 }} ... />Use your computer's IP instead of localhost:
1# .env
2EXPO_PUBLIC_STRAPI_URL=http://192.168.1.100:1337Restart with cleared cache:
npx expo start --go --clearAdd headerShown: false to the tabs Stack.Screen in your root layout.
1your-project/
2├── client/
3│ ├── app/
4│ │ ├── _layout.tsx
5│ │ ├── (tabs)/
6│ │ │ ├── _layout.tsx
7│ │ │ ├── index.tsx
8│ │ │ └── blog.tsx
9│ │ └── article/
10│ │ └── [slug].tsx
11│ ├── components/
12│ │ ├── article-card.tsx
13│ │ ├── strapi-image.tsx
14│ │ └── blocks/
15│ │ ├── index.ts
16│ │ ├── block-renderer.tsx
17│ │ ├── hero.tsx
18│ │ ├── section-heading.tsx
19│ │ ├── card-grid.tsx
20│ │ ├── content-with-image.tsx
21│ │ ├── markdown-text.tsx
22│ │ └── faqs.tsx
23│ ├── data/
24│ │ └── loaders.ts
25│ ├── hooks/
26│ │ ├── useArticles.ts
27│ │ └── useLandingPage.ts
28│ ├── lib/
29│ │ ├── strapi-api.ts
30│ │ └── utils.ts
31│ ├── types/
32│ │ └── index.ts
33│ ├── global.css
34│ ├── tailwind.config.js
35│ └── package.json
36└── server/
37 └── ... (Strapi)Now that you have a working app, consider:
Happy coding! If you have questions or run into issues, check the Expo and Strapi Discord communities for help.