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.