Deploying a Self-Hosted Expo API Routes using Docker

GuidesMon Oct 28 2024
Deploying a Self-Hosted Expo API Routes using Docker

Expo API Routes were introduced with Expo 50, and it's a feature that is still not well-known. New developers tend to miss the power of this feature.

Expo Routes introduce page navigation similar to Next.js but can also create server endpoints and REST API in WinterGC-compliant environment. In other words, you can write a GET/POST/PUT routes that can handle server logic in the same app folder!

With Server Side Rendering comming to Expo 52 in the next release, hosting will be crucial to make your app performant and scalable.

Let’s dive into how to use Expo Routes to create a custom API endpoint that we can use in our app:

terminal
npx create-expo-app

First, let’s set the output of the bundler to a server instead of static. This will generate server code as well as a static web app for your Expo project:

app.json
"web": {  "bundler": "metro",  "output": "server" }

Let’s create our first REST API. Inside the app project folder, a file with +api.ts will be treated as a route API and should export POST, GET functions to handle the request:

terminal
touch app/api/echo+api.ts

Here we just parse the body and return it as a response:

app/api/echo+api.ts
export async function POST(request: Request) {  const message = await request.json();  return Response.json(message); }

Run the development server to test our new API:

terminal
npm run start  > expo start   Metro waiting on exp://192.168.0.228:8081   Web is waiting on http://localhost:8081   Using Expo Go  Press s switch to development build   Press a open Android  Press i open iOS simulator  Press w open web   Press j open debugger  Press r reload app  Press m toggle menu  Press o open project code in your editor   Press ? show all commands  Logs for your project will appear below. Press Ctrl+C to exit.

Now the /api/echo should be available at http://localhost:8081/api/echo

terminal
curl -X POST http://localhost:8081/api/echo \  -H "Content-Type: application/json" \  -d '{"hello":"world"}' {"hello":"world"} 

You can also call the new API within your app:

HomeScreen.tsx
import { Button } from 'react-native';  async function fetchEcho() {  const response = await fetch('/api/echo', {  method: 'POST',  headers: {  'Content-Type': 'application/json',  },  body: JSON.stringify({ message: 'Hello' }),  });  const data = await response.json();  alert(JSON.stringify(data)); }  export default function HomeScreen() {  return <Button onPress={() => fetchEcho()} title="Call Echo" />; }

Note that this will only work for the web since it runs on the same host. For production, you should set the origin for expo-router in the app.json file:

app.json
"plugins": [  ["expo-router", {  "origin": "http://192.168.0.228:8080"  }] ],

Next, let’s export our project. This command will bundle all the functions and static files into a single dist folder:

terminal
npm run expo export -p web

This will generate a dist directory with client and server.

terminal
npm install express compression morgan -D

Expo documentation provides a server script based on Express.js that can serve the exported project:

server.js
#!/usr/bin/env node  const path = require('path'); const { createRequestHandler } = require('@expo/server/adapter/express');  const express = require('express'); const compression = require('compression'); const morgan = require('morgan');  const CLIENT_BUILD_DIR = path.join(process.cwd(), 'dist/client'); const SERVER_BUILD_DIR = path.join(process.cwd(), 'dist/server');  const app = express();  app.use(compression());  // http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header app.disable('x-powered-by');  process.env.NODE_ENV = 'production';  app.use(  express.static(CLIENT_BUILD_DIR, {  maxAge: '1h',  extensions: ['html'],  }) );  app.use(morgan('tiny'));  app.all(  '*',  createRequestHandler({  build: SERVER_BUILD_DIR,  }) );  const port = process.env.PORT || 3000;  app.listen(port, () => {  console.log(`Express server listening on port ${port}`); });

You should be able to run the server locally with:

terminal
node server.js Express server listening on port 3000

You can host the project by just copying the dist and server.js and hosting it on any server manually.

But, let's use Docker to make a nice container that we can deploy anywhere:

Dockerfile
FROM node:20-alpine AS base  FROM base AS builder  RUN apk add --no-cache gcompat WORKDIR /app  COPY . ./  RUN npm ci && \  npm run export && \  npm prune --production  FROM base AS runner WORKDIR /app  RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nodejs  RUN npm install express compression morgan @expo/server COPY --from=builder --chown=nodejs:nodejs /app/dist /app/dist COPY --from=builder --chown=nodejs:nodejs /app/server.js /app/server.js  USER nodejs EXPOSE 3000  CMD ["node", "/app/server.js"]

With this Dockerfile, you can generate a compact version of your project, which will contain both the static web version of your mobile app and also the backend API:

terminal
docker build -t expo-api .
terminal
 docker images expo-api REPOSITORY TAG IMAGE ID CREATED SIZE expo-api latest a78ef09bc8c8 3 minutes ago 193MB

Running the container should be straightforward too:

 docker run -p 3000:3000 expo-api Express server listening on port 3000
terminal
curl -X POST http://localhost:3000/api/echo \  -H "Content-Type: application/json" \  -d '{"hello":"world"}' {"hello":"world"} 

Cool, right?

With this approach, you can implement both the mobile app and its backend dependencies in one place, generating a nice container to be deployed anywhere. No need to create a separate project whose only purpose is to provide your backend for your mobile app.