Intro
Historically speaking, trying to handle navigation on a universal app (that targets web and mobile) was a pain in the ass.
Navigation on the web usually is quite simple, and Next.js did a fantastic job with its file system-based router.
In mobile land, things are not that simple. Fernando Rojo did a fantastic job with Solito, which is a wrapper around React Navigation and Next.js that lets you share navigation code across platforms. This project gained a lot of traction, and the community started to work on solving the problem of navigation for universal apps.
Introducing Expo Router
I heard about Expo Router a few months ago, and I instantly loved the concept:
What if we could have something like Next.js file-system-based router, but for universal apps? That is Expo Router.
What's the big deal about this library?
"Expo Router brings the best routing concepts from the web to native iOS and Android apps. Every file in the app directory automatically becomes a route in your mobile navigation, making it easier than ever to build, maintain, and scale your project."
If you've had to deal with deep linking on your mobile apps in the past, you already know that this is another major pain.
Expo Router was built on top of React Navigation, and the entire deep linking system is automatically generated, meaning that you can share the same link on the web and mobile, and the deep link will automatically work 🤯.
No more weird mapping and matching routes.
There are tons of additional features like Offline support, but if you want to learn more about all these features, here's the official docs
Given that this library is still in beta, some links may change
Building a magazine app
I wanted to test features like tab and stack navigation to get a first sense of how it feels to work on a real app using Expo Router, so I decided to build a very simple magazine app that shows a list of news and allows the user to navigate to each news to read more about it.
For this demo app, I'll be using SpiroKit, which is a React Native UI kit I built. Given that is a paid product, feel free to follow this tutorial with your own UI.
Project setup
With SpiroKit
If you've decided to use SpiroKit, follow these steps to quickly generate a new expo project with SpiroKit and Expo Router:
Get your SpiroKit license here.
Create a new project using the template
expo init my-app --template @spirokit/expo-router-template
- Add SpiroKit to your project: Download the
spirokit-core-[version].tgz
file from Gumroad and add it to the root of your project.
Install the package by running the following command:
yarn add ./spirokit-core-[version].tgz
With your own UI
Run the following command to create a new project with expo-router:
npx create-react-native-app -t with-router
First use
After creating my first project using the expo template, I just run yarn start
.
By default, the starter templates don't include the app folder, so there is nothing to show. But we will get a friendly welcome message that will let us create our first route by only clicking the “touch app/index.js” button
After clicking the button, I instantly got an update on all my devices (both web and mobile)
After returning to my code, I confirmed that the new app/index.js
file was created.
// app/index.js
import { Link, Stack } from "expo-router";
import { StyleSheet, Text, View } from "react-native";
export default function Page() {
return (
<View style={styles.container}>
<View style={styles.main}>
<Text style={styles.title}>Hello World</Text>
<Text style={styles.subtitle}>This is the first page of your app.</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
padding: 24,
},
main: {
flex: 1,
justifyContent: "center",
maxWidth: 960,
marginHorizontal: "auto",
},
title: {
fontSize: 64,
fontWeight: "bold",
},
subtitle: {
fontSize: 36,
color: "#38434D",
},
});
Given that Expo Router is file-system based, we'll need to create new directories and files based on our needs.
My magazine app will use a bottom tab navigation with 2 main sections:
- News
- Settings
At the same time, the "News" section will use a Stack navigator to allow us to navigate to the details page. More about this below.
Let's start building our app!
1. Adding the Tab navigator
We need to add a tab navigator so we can navigate between the "news" and "settings" tabs.
Expo Router includes a feature called "Layout Routes". From the official docs:
"To render shared navigation elements like a header, tab bar, or drawer, you can use a Layout Route. If a directory contains a file named _layout.js, it will be used as the layout component for all the sibling files in the directory."
Let's create our app/_layout.js
and add the Tab navigation we need:
// app/_layout.js
import { SpiroKitProvider, usePoppins, useSpiroKitTheme } from "@spirokit/core";
import { Tabs } from "expo-router";
// Setting up some global preferences for theming
const theme = useSpiroKitTheme({
config: {
colors: {
primaryGray: "coolGray",
primaryDark: "coolDark",
},
},
});
export default function Layout() {
const fontLoaded = usePoppins();
if (!fontLoaded) return <></>;
return (
{/* Required for SpiroKit */}
<SpiroKitProvider theme={theme}>
{/* This will add the Tabs navigator */}
<Tabs screenOptions={{ headerShown: false }}>
{/* This allows us to further customize any given route */}
<Tabs.Screen
name="index"
options={{
// This tab will no longer show up in the tab bar.
href: null,
}}
/>
</Tabs>
</SpiroKitProvider>
);
}
I'm excluding the index route from the Tab Bar by setting the href to null. I just want "news" and "settings" to be included in the Tab Bar.
2. Updating the app/index.js
file
With the tabs navigator in place, I wanted to have a welcome screen (app/index.js
), with a button that redirects to the news section.
Expo Router provides many options to move between routes, but given that it's still in beta, some things may not be supported yet. In this case, I'm using the useLink
hook to move between routes.
// app/index.js
import * as React from "react";
import { useLink } from "expo-router";
import { HomeIcon } from "react-native-heroicons/outline";
import { Button, Image, LargeTitle, VStack } from "@spirokit/core";
export default function Page() {
// The useLink hook allows us to navigate between routes
const link = useLink();
return (
<VStack
space={4}
justifyContent="center"
alignItems={"center"}
flex={1}
backgroundColor={{
linearGradient: {
colors: ["primary.600", "emerald.800"],
start: [0, 1],
end: [1, 0],
},
}}
>
<Image
width={64}
borderWidth={8}
borderColor="primary.500"
height={64}
borderRadius="full"
source={{
uri: "https://images.pexels.com/photos/1369476/pexels-photo-1369476.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1",
}}
></Image>
<LargeTitle color="white" width={"1/2"} textAlign="center">
Welcome to magazine App
</LargeTitle>
{/* Here I'm using the onPress event to trigger the navigation */}
{/* This won't work until we create the news route below */}
<Button
variant="secondary"
textColor="white"
colorMode={"dark"}
IconLeftComponent={HomeIcon}
width="auto"
onPress={() => link.push("news")}
>
Home
</Button>
</VStack>
);
}
After these changes, the welcome screen should look like this:
3. Adding the "News" and "Settings" tabs
Let's start by creating the app/news
and app/settings
directories.
mkdir news
mkdir settings
Your project should look like this:
├── app
│ ├── index.js
│ ├── _layout.js
│ ├── news
│ │ ├── Empty directory
│ └── settings
│ │ ├── Empty directory
├── app.json
├── babel.config.js
├── index.js
├── package.json
├── README.md
└── yarn.lock
We'll also need to define which navigator to use on each tab. In this case, I decided to use stack navigators on each tab.
Let's create the index.js
and _layout.js
files inside "news" and "settings" directories:
touch ./app/news/index.js ./app/news/_layout.js ./app/settings/index.js ./app/settings/_layout.js
Now, your project structure should look like this:
├── app
│ ├── index.js
│ ├── _layout.js
│ ├── news
│ │ ├── index.js
│ │ └── _layout.js
│ └── settings
│ ├── index.js
│ └── _layout.js
├── app.json
├── babel.config.js
├── index.js
├── package.json
├── README.md
└── yarn.lock
To add the stack navigators, add this to the app/news/_layout.js
and app/settings/_layout.js
files:
// app/news/_layout.js
// app/settings/_layout.js
import { Stack } from "expo-router";
export default function Layout() {
return (
<Stack screenOptions={{ headerShown: false }}></Stack>;
)
}
Finally, let's customize the app/news/index.js
and app/settings/index.js
files to include a simple message:
// app/news/index.js
import { Center, LargeTitle } from "@spirokit/core";
export default function News() {
return (
<Center flex={1}>
<LargeTitle>News route</LargeTitle>
</Center>
)
}
// app/settings/index.js
import { Center, LargeTitle } from "@spirokit/core";
export default function News() {
return (
<Center flex={1}>
<LargeTitle>Settings route</LargeTitle>
</Center>
)
}
You should now be able to navigate between tabs 🎉
4. Adding UI to the "News" Route
Let's add some UI to our news
route. Don't worry if you are not using SpiroKit. The key takeaway here is that we'll use the useLink
hook from Expo Router to navigate to the news details route.
// app/news/index.js
import {
Button,
VerticalCard,
Badge,
Avatar,
TitleThree,
Subhead,
Image,
Footnote,
Box,
HorizontalCard,
VStack,
HStack,
LargeTitle,
useColorModeValue,
Pressable,
} from "@spirokit/core";
import { useLink } from "expo-router";
import { ScrollView } from "react-native";
import { BellIcon, LightBulbIcon } from "react-native-heroicons/outline";
export default function News() {
const link = useLink();
return (
<Box
flex={1}
backgroundColor={useColorModeValue("white", "primaryDark.1")}
safeArea
>
<ScrollView>
<VStack space={4} flex={1} padding={4}>
<HStack
space={4}
justifyContent="space-between"
alignItems={"center"}
>
<LargeTitle>News</LargeTitle>
<Button IconLeftComponent={BellIcon} size="sm" width="auto">
Subscribe
</Button>
</HStack>
{/* We are using the push method to navigate to news details */}
<Pressable onPress={() => link.push("/news/1234")}>
<MainTravelCard></MainTravelCard>
</Pressable>
<SecondaryTravelCard></SecondaryTravelCard>
<FoodCard></FoodCard>
</VStack>
</ScrollView>
</Box>
);
}
const MainTravelCard = () => {
return (
<VerticalCard
BadgeComponent={<Badge>Travel</Badge>}
UserAvatarComponent={
<Avatar
alt="Siv Marko profile image"
source={{ uri: "https://i.imgur.com/pfR8Ytj.png" }}
></Avatar>
}
userName="Siv Marko"
TitleComponent={
<TitleThree>Resting place of Australia's last convict ship</TitleThree>
}
DescriptionComponent={
<Subhead>
Wellington, New Zealand (CNN) - The storm that struck the Edwin Fox on
February 1873 might sound dramatic.
</Subhead>
}
DateComponent={<Footnote>15th June 2021</Footnote>}
AssetComponent={
<Image
source={{ uri: "https://i.imgur.com/1lSpdz3.png" }}
alt="Image of a ship"
></Image>
}
></VerticalCard>
);
};
const SecondaryTravelCard = () => {
return (
<HorizontalCard
UserAvatarComponent={
<Avatar
alt="Kenny Grimes profile image"
source={{ uri: "https://i.imgur.com/mwax0m0.png" }}
></Avatar>
}
userName="Kenny Grimes"
TitleComponent={
<TitleThree>
Emirates introduces digital health verification for UAE passengers
</TitleThree>
}
DescriptionComponent={
<Subhead>
Emirates and the Dubai Health Authority (DHA) have begun to implement
full digital verification of Covid-19 medical records
</Subhead>
}
DateComponent={<Footnote>15th June 2021</Footnote>}
AssetLeftComponent={
<Image
source={{ uri: "https://i.imgur.com/EflHxyi.png" }}
alt="Image of a ship"
></Image>
}
></HorizontalCard>
);
};
const FoodCard = () => {
return (
<HorizontalCard
UserAvatarComponent={
<Avatar
alt="Paula Green profile image"
source={{ uri: "https://i.imgur.com/Vbzbh6Z.png" }}
></Avatar>
}
userName="Paula Green"
TitleComponent={
<TitleThree>
The Best Marinara Sauce You Can Get At The Store
</TitleThree>
}
DateComponent={<Footnote>15th June 2021</Footnote>}
AssetRightComponent={LightBulbIcon}
></HorizontalCard>
);
};
5. Adding UI to the "Settings" Route (Optional)
I wanted to use the settings
route to test if I could support switching between light and dark modes with Expo Router.
Following the Expo Router docs, I learned about the component, which allows us to interact with the NavigationContainer (managed by Expo Router) to set things like "theme".
Let's customize the UI to add a switch that allows us to toggle dark mode:
// app/settings/index.js
import {
Body,
Box,
HStack,
LargeTitle,
Switch,
useColorModeValue,
VStack,
useColorMode,
Button,
Input,
Subhead,
} from "@spirokit/core";
import { RootContainer } from "expo-router";
import { DarkTheme, LightTheme } from "@react-navigation/native";
import { LogoutIcon, UserIcon, LinkIcon } from "react-native-heroicons/outline";
export default function Settings() {
const { toggleColorMode, colorMode } = useColorMode();
return (
<Box flex={1}>
{/* I'm using the global colorMode prop provided by SpiroKit to dinamically set the theme */}
<RootContainer theme={colorMode === "light" ? LightTheme : DarkTheme} />
{/* Header */}
<Box
safeAreaTop
justifyContent={"flex-end"}
padding={4}
{/* I'm using the `useColorModeValue` hook provided by SpiroKit to set different colors for light and dark mode */}
backgroundColor={useColorModeValue("primary.500", "primary.300")}
minHeight={32}
>
<LargeTitle color={useColorModeValue("white", "primaryGray.900")}>
Settings
</LargeTitle>
</Box>
{/* Body */}
<Box
flex={1}
backgroundColor={useColorModeValue("white", "primaryDark.1")}
padding={4}
>
<VStack space={4} flex={1}>
<HStack justifyContent={"space-between"} alignItems="center">
<Body flex={1}>Dark mode</Body>
<Switch onValueChange={toggleColorMode}></Switch>
</HStack>
<Input
IconLeftComponent={UserIcon}
LabelComponent={<Subhead>Name</Subhead>}
defaultValue="Mauro"
></Input>
<Input
IconLeftComponent={UserIcon}
LabelComponent={<Subhead>Lastname</Subhead>}
defaultValue="Garcia"
></Input>
<Input
IconLeftComponent={LinkIcon}
isDisabled
LabelComponent={<Subhead>Twitter handle</Subhead>}
defaultValue="https://www.twitter.com/mauro_codes"
></Input>
</VStack>
<Button IconLeftComponent={LogoutIcon}>Logout</Button>
</Box>
</Box>
);
}
If everything goes well, we should now be able to toggle between light and dark mode
6. Adding a dynamic route for the news details screen
From the Expo docs:
"Dynamic routes match any unmatched path at a given segment level. For example, /blog/[id] is a dynamic route. The variable part ([id]) is called a "slug"."
We are going to use this pattern to navigate from the "news" route to the news details ("news"/[id]).
Remember, Expo Router is based on your file system, so let's start by creating a new file for this dynamic route:
touch ./app/news/[id].js
Inside our new [id].js
file, let's add some UI and see how we can access the id
param.
Given this is a demo, I'm using hardcoded data. In real life, we would use the id from the URL to request the information using
fetch
oraxios
.
import { useLink } from "expo-router";
import { Platform, ScrollView } from "react-native";
import {
Avatar,
Subhead,
Image,
Box,
VStack,
HStack,
LargeTitle,
useColorModeValue,
ZStack,
Body,
Button,
} from "@spirokit/core";
import { ChevronLeftIcon } from "react-native-heroicons/outline";
export default function NewsDetails({ route }) {
// Extracting the id param from the route
const id = route.params.id;
// This should be replaced by real data coming from an external API
const content = loremIpsum;
return (
<Box flex={1} backgroundColor={useColorModeValue("white", "primaryDark.1")}>
<Header></Header>
<ScrollView>
<VStack space={4} flex={1} padding={4}>
<Body>{content}</Body>
</VStack>
</ScrollView>
</Box>
);
}
const Header = () => {
const link = useLink();
return (
<>
<ZStack minHeight={56} overflow="hidden" width="full">
<Image
height={56}
width="full"
resizeMode="cover"
source={{ uri: "https://i.imgur.com/1lSpdz3.png" }}
alt="Image of a ship"
></Image>
<VStack
justifyContent={"space-between"}
backgroundColor={"black:alpha.40"}
padding={4}
width="full"
height={"full"}
>
{Platform.OS === "web" ? (
<Button
size="sm"
width="auto"
alignSelf={"flex-start"}
onPress={() => link.back()}
IconLeftComponent={ChevronLeftIcon}
></Button>
) : null}
<LargeTitle numberOfLines={3} color={"white"}>
Resting place of Australia's last convict ship
</LargeTitle>
</VStack>
</ZStack>
<AuthorLine></AuthorLine>
</>
);
};
const AuthorLine = () => {
return (
<HStack
padding={4}
justifyContent={"space-between"}
alignItems={"center"}
space={4}
>
<HStack space={4} flex={1} alignItems="center">
<Avatar
size={"sm"}
alt="Siv Marko profile image"
source={{ uri: "https://i.imgur.com/pfR8Ytj.png" }}
></Avatar>
<Body>Siv Marko</Body>
</HStack>
<Subhead flex={1} textAlign="right">
15th June 2021
</Subhead>
</HStack>
);
};
const loremIpsum = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus blandit faucibus justo, at eleifend sapien pellentesque ut. Curabitur ultrices eget arcu sit amet luctus. Mauris accumsan ut mauris eget pharetra. Quisque dignissim sed leo sed condimentum. Nullam ligula nisi, pellentesque sit amet lacus eget, malesuada tempus lorem. Pellentesque fringilla erat a faucibus semper. Sed posuere tristique vulputate.
Nam convallis tempor dictum. Donec maximus nisl a tempor condimentum. Fusce egestas velit id ante consectetur feugiat. Nam non ligula metus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Fusce efficitur tellus leo, non vehicula lorem ornare id. Vivamus enim mauris, volutpat ut luctus semper, hendrerit eu dolor. Nunc a sapien ac ex tempus tempor. Cras odio augue, porta vitae venenatis id, sagittis at tortor. Etiam id tristique tortor, in eleifend dui. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.`;
If you reload your app, you should now be able to navigate to the news details screen.
Note that you didn't have to add any additional configuration to use this last route. Given that we already setup the stack navigator for the "news" directory, every child automatically becomes a valid route ✨✨
Conclusion
Congrats! If you are still here, you managed to build a small app that leverages a few of the available features on Expo Router.
This is just the beginning. We are in the early days of this library, so everything is changing really fast.
If you enjoyed this article, don't hesitate to leave a comment or reach out to me on Twitter. My DM's are always open.
I would love to hear your thoughts!
Happy coding!
Top comments (0)