Building a Terminal App with React Native
Build a React Native app that communicates with your Node.js backend via HTTP. The backend handles Paystack Terminal API calls and webhook processing. Use React Context or Redux for state management. Design a split-view tablet layout with product catalog on the left and order summary on the right. Poll your backend for payment status updates during active transactions.
React Native Terminal App Architecture
The architecture is identical to the Flutter version in principle. React Native is the cashier frontend. Your Node.js backend is the middle layer. Paystack Terminal is the payment device.
React Native app responsibilities: Display products, build orders, show payment status, handle cashier interactions.
Backend responsibilities: Store data, call Paystack Terminal API, process webhooks, push status updates.
Communication: React Native uses fetch or axios to call your backend REST API. For real-time updates, use polling or EventSource (Server-Sent Events). React Native does not have native EventSource support out of the box, so polling every 2 seconds is the simplest approach.
// backend endpoints (Node.js)
// POST /api/orders - create order
// POST /api/terminal/charge - push to terminal
// GET /api/terminal/status/:ref - check payment status
// GET /api/products - list products
// React Native calls these endpoints
// The backend handles Paystack Terminal API internally
API Communication Layer
// api-layer.js (used by React Native app, but shown as backend endpoints)
var express = require('express');
var router = express.Router();
// Product listing for the POS catalog
router.get('/api/products', async function(req, res) {
var products = await db.query(
'SELECT id, name, price, category, image_url FROM products WHERE active = true ORDER BY category, name'
);
return res.json(products.rows);
});
// Create order from items
router.post('/api/orders', async function(req, res) {
var items = req.body.items;
var total = items.reduce(function(sum, item) {
return sum + (item.price * item.quantity);
}, 0);
var order = await db.query(
'INSERT INTO orders (items, total_amount, status, created_at) VALUES ($1, $2, $3, NOW()) RETURNING *',
[JSON.stringify(items), total, 'draft']
);
return res.json(order.rows[0]);
});
// Push payment to terminal
router.post('/api/terminal/charge', async function(req, res) {
var orderId = req.body.order_id;
var terminalId = req.body.terminal_id;
var order = await db.query('SELECT total_amount FROM orders WHERE id = $1', [orderId]);
var amount = order.rows[0].total_amount;
var reference = 'RN-' + orderId + '-' + Date.now();
await db.query('UPDATE orders SET status = $1, reference = $2 WHERE id = $3', ['pending', reference, orderId]);
await pushPaymentToTerminal(terminalId, amount, reference);
return res.json({ reference: reference, status: 'pushed' });
});
// Poll payment status
router.get('/api/terminal/status/:reference', async function(req, res) {
var order = await db.query(
'SELECT status, total_amount FROM orders WHERE reference = $1',
[req.params.reference]
);
if (order.rows.length === 0) return res.json({ status: 'unknown' });
return res.json(order.rows[0]);
});
State Management with Context
For a POS app, React Context is sufficient. You need to track three pieces of state: the current order (items and total), the terminal status (idle, waiting, processing), and the connectivity status (online or offline).
The state shape maps to your backend data. When the React Native app polls the backend and gets a status update, it dispatches a state change that updates the UI.
// state management runs in React Native, but the backend
// provides the data it displays:
// Backend webhook handler updates order status
async function handleChargeWebhook(webhookData) {
var reference = webhookData.data.reference;
var status = webhookData.data.status;
if (status === 'success') {
await db.query('UPDATE orders SET status = $1, paid_at = NOW() WHERE reference = $2', ['paid', reference]);
} else {
await db.query('UPDATE orders SET status = $1 WHERE reference = $2', ['failed', reference]);
}
}
// React Native polls GET /api/terminal/status/:reference
// When it sees status change from 'pending' to 'paid' or 'failed',
// it updates the local state and shows the appropriate screen
Optimistic updates. When the cashier taps "Charge," update the local state to "pushing" immediately. Do not wait for the API response. If the API call fails, revert to the previous state and show an error.
Tablet Layout Design
Lock the app to landscape orientation on tablets. Use a split-view layout.
Left panel (60% width): Product catalog as a scrollable grid. Categories as horizontal tabs at the top. Each product is a card with the name, price, and a large tap target.
Right panel (40% width): Order summary. List of added items with quantities and subtotals. Total at the bottom. "Charge" button below the total, full width, high contrast.
Payment overlay: When a payment is in progress, show a full-screen overlay with the status. Large icon (spinner, check, or X), clear text ("Waiting for card...", "Payment complete!"), and a cancel button for pending payments.
Accessibility. Use large fonts (minimum 16sp for body text, 24sp for amounts). High contrast colours. The cashier might be reading the screen from 30cm away in bright lighting.
Navigation. Use a bottom tab bar with 3 tabs: POS (main screen), Orders (history), Settings. Keep it simple. The POS tab is where cashiers spend 95% of their time.
Offline Support
React Native can cache data locally for offline use.
// offline-products.js (backend endpoint with cache headers)
router.get('/api/products', async function(req, res) {
var products = await db.query(
'SELECT id, name, price, category FROM products WHERE active = true ORDER BY name'
);
// Set cache headers for the React Native app
res.set('Cache-Control', 'public, max-age=3600'); // 1 hour
return res.json(products.rows);
});
// React Native stores the response in AsyncStorage
// On next launch, if offline, load from AsyncStorage
// When online, fetch fresh data and update the cache
What works offline: Browsing the product catalog, building an order, calculating totals.
What does not work offline: Pushing payments to the terminal (both the app and the terminal need internet), receiving webhooks, checking payment status.
Queue orders, not payments. If the cashier builds an order while offline, save it locally. When connectivity returns, sync the order to the backend and then push the payment. Do not attempt to charge while offline.
Hardware Integration
React Native can integrate with hardware accessories common in POS setups.
Barcode scanners. USB barcode scanners act as keyboards. When a barcode is scanned, the scanner types the barcode digits into the active text field. Use a hidden text input to capture barcode scans. Look up the scanned code in your product database.
Receipt printers. Bluetooth receipt printers can be accessed through React Native Bluetooth libraries. The integration is more complex than in native apps. Consider using a network printer instead, which you access through your backend.
Cash drawers. Cash drawers connected to receipt printers can be triggered by the printer's kick command. Send the command through your backend when a cash payment is recorded.
For barcode scanning specifics, see the retail checkout guide.
Deploying the React Native POS App
POS apps have unique deployment considerations.
Distribution. If the app is for your own business, use internal distribution (TestFlight for iOS, internal testing track for Android). You do not need to publish to the public app stores. If you are selling the POS solution to other businesses, publish to the stores with appropriate business category.
Updates. Use over-the-air updates (CodePush or Expo Updates) for quick fixes. Reserve store releases for major changes that require native module updates. OTA updates let you fix bugs without waiting for app store review.
Device management. If you manage multiple tablets across multiple locations, consider a Mobile Device Management (MDM) solution to push updates, lock devices to your app (kiosk mode), and monitor device health remotely.
Lock to kiosk mode. In retail, the tablet should only run your POS app. Android supports kiosk mode (lock task mode) that prevents users from switching apps or accessing settings. Configure this for production devices.
Key Takeaways
- ✓React Native talks to your backend, not to Paystack. The backend holds the secret key and manages Terminal API calls.
- ✓Use React Context for simple apps or Redux for complex multi-terminal setups. Track terminal state, order state, and connectivity.
- ✓Design for tablets in landscape mode. Use flexbox with a split-view layout. Large touch targets for speed.
- ✓Poll the backend for payment status every 2 seconds during active transactions. Stop polling when idle.
- ✓Cache products locally with AsyncStorage or a local database for offline order building.
- ✓Use React Navigation with a simple tab or drawer navigator. POS apps need few screens: catalog, order, payment status, settings.
Frequently Asked Questions
- Should I choose React Native or Flutter for a POS app?
- If your team knows React, choose React Native. If your team knows Dart or you want the best rendering performance, choose Flutter. Both can build excellent POS apps. The choice is about team expertise, not technical superiority.
- Can I use Expo for a Paystack Terminal app?
- Expo works for the basic app structure and UI. If you need native hardware integration (Bluetooth printer, USB barcode scanner), you will need to eject from Expo or use development builds with custom native modules. Expo is fine for the initial prototype.
- How do I handle multiple cashiers on the same device?
- Implement a simple login screen. Each cashier logs in with a PIN or username. The app loads their assigned terminal and permissions. Track which cashier processed each order for accountability.
- What screen size should I target?
- Target 10-inch tablets as the primary device. Test on 7-inch and 12-inch screens to ensure the layout adapts. Use responsive flexbox layouts rather than fixed pixel dimensions.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon