In the age of personal health tech, developers have access to an unprecedented amount of user data from wearables like Garmin watches and Oura rings. The challenge? This data is locked away in separate, walled gardens. If you want to build an application that provides holistic health insights, you're forced to wrangle multiple APIs, authentication flows, and inconsistent data structures.
This is where the Backend for Frontend (BFF) pattern shines. Instead of letting your client-side application communicate directly with these external services, we'll build a dedicated backend layer right inside our Next.js application using API Routes. This BFF will act as a centralized hub to handle the complexity, providing a single, clean API for our frontend.
In this architectural guide, we'll build a wearable data aggregator. We will focus on designing a system that can:
- Securely connect to both the Garmin and Oura APIs via OAuth 2.0.
- Normalize their different sleep data formats into a unified schema.
- Listen for real-time updates using webhooks.
This approach not only simplifies frontend logic but also dramatically improves security by keeping all tokens and credentials on the server, completely hidden from the browser.
Prerequisites:
- Solid understanding of React and Next.js.
- Familiarity with REST APIs and basic backend concepts.
- Node.js (v18 or later) and npm/yarn installed.
- A developer account for both the Garmin Connect Developer Program and the Oura API.
Understanding the Problem: The BFF Advantage
Modern applications often stitch together multiple microservices and third-party APIs. A frontend that communicates directly with all of them becomes complex and brittle.
The Old Way (Without BFF):
- Client-side Complexity: The React frontend would need logic to handle OAuth redirects and token storage for both Garmin and Oura.
- Security Risks: Storing access tokens in
localStorageor browser memory is vulnerable to XSS attacks. - Data Mismatch: The frontend would have to fetch two different data structures for sleep and normalize them on the fly, leading to duplicated logic and performance issues.
The New Way (With a Next.js BFF): Our Next.js API Routes will serve as the BFF layer.
- Simplified Frontend: The frontend only needs to make simple requests to our own API (e.g.,
/api/dashboard). - Enhanced Security: All OAuth tokens are stored securely in a server-side database. The frontend never sees them.
- Centralized Logic: Data fetching, normalization, and API orchestration are handled in one place, making the system easier to maintain and scale.
Prerequisites: Setting Up Your API Credentials
Before we write any code, you need to register an application with both Garmin and Oura to get your API credentials.
-
Oura API:
- Go to the "My Applications" section in your Oura Cloud account.
- Create a new application.
- For the "Redirect URI," enter
http://localhost:3000/api/connect/oura/callback. - Note your Client ID and Client Secret.
-
Garmin Connect API:
- Request access to the developer program. This can take time for approval.
- Once approved, create a new application.
- Set the "OAuth2 Redirect URL" to
http://localhost:3000/api/connect/garmin/callback. - Note your Consumer Key (Client ID) and Consumer Secret.
Store these credentials securely in your Next.js project's .env.local file:
# .env.local
OURA_CLIENT_ID=your_oura_client_id
OURA_CLIENT_SECRET=your_oura_client_secret
GARMIN_CLIENT_ID=your_garmin_client_id
GARMIN_CLIENT_SECRET=your_garmin_client_secret
# For encrypting session cookies
NEXTAUTH_SECRET=generate_a_strong_secret_key
DATABASE_URL=your_database_connection_string
Step 1: Securely Managing Multi-Provider OAuth 2.0
What we're doing
The core of our aggregator is securely authenticating users with third-party services. We need a system to store and refresh OAuth 2.0 tokens for each user and each connected service. We'll store these tokens in a database, associated with the user's session.
For our database, a simple schema like this will work (using Prisma as an example):
// schema.prisma
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
accounts Account[]
}
Using a library like NextAuth.js simplifies this immensely, as it's designed to handle the OAuth flow and database storage.
Implementation
We'll create a dynamic API route that handles all NextAuth operations.
// pages/api/auth/[...nextauth].js
import NextAuth from 'next-auth';
import OuraProvider from 'next-auth/providers/oura'; // Assuming a custom or community provider
import GarminProvider from 'next-auth/providers/garmin'; // Assuming a custom or community provider
// NOTE: You might need to create custom provider configurations if official ones don't exist.
// This is a conceptual example.
export default NextAuth({
providers: [
// Oura Provider Configuration
{
id: "oura",
name: "Oura",
type: "oauth",
version: "2.0",
authorization: {
url: "https://cloud.ouraring.com/oauth/authorize",
params: { scope: "personal sleep" }, // Request scopes you need
},
token: "https://api.ouraring.com/oauth/token",
userinfo: "https://api.ouraring.com/v2/usercollection/personal_info",
clientId: process.env.OURA_CLIENT_ID,
clientSecret: process.env.OURA_CLIENT_SECRET,
profile(profile) {
// Map the profile data to the user object
return {
id: profile.id,
email: profile.email,
};
},
},
// Garmin Provider would follow a similar structure
// ...
],
// ... other NextAuth configuration (database adapter, etc.)
});
On the frontend, connecting a new service is as simple as adding a button:
// components/ConnectButton.jsx
import { signIn } from 'next-auth/react';
const ConnectButton = ({ providerId, children }) => {
return (
<button onClick={() => signIn(providerId)}>
{children}
</button>
);
};
// Usage on a page
<ConnectButton providerId="oura">Connect Oura</ConnectButton>
How it works
- A user clicks the "Connect" button, which calls
signIn('oura'). - NextAuth.js redirects the user to the Oura authorization screen.
- After the user grants permission, Oura redirects them back to our callback URL (
/api/auth/callback/oura). - NextAuth.js handles the callback, exchanges the authorization code for an
access_tokenandrefresh_token. - It then saves these tokens securely in the
Accounttable in our database, linked to theUser. The session is managed via a secure,httpOnlycookie.
Step 2: Creating a Unified Data Schema for Sleep
What we're doing
Garmin and Oura both provide detailed sleep data, but their JSON structures are different. Our goal is to create a single, unified UnifiedSleepRecord format that our frontend can easily consume.
- Oura Sleep Data (
/v2/usercollection/sleep): Provides durations likedeep,rem,light, andawakein seconds. - Garmin Sleep Data: Also provides durations like
deepSleepDurationInSeconds,lightSleepDurationInSeconds,remSleepInSeconds, andawakeDurationInSeconds.
The core metrics are remarkably similar, making normalization straightforward.
Implementation
First, let's define our target interface in TypeScript.
// types/health.ts
export interface UnifiedSleepRecord {
source: 'garmin' | 'oura';
date: string; // YYYY-MM-DD
totalSleepSeconds: number;
deepSleepSeconds: number;
lightSleepSeconds: number;
remSleepSeconds: number;
awakeSeconds: number;
sleepScore?: number;
}
Next, we create normalization functions to transform the raw API data into our unified structure.
// lib/normalization.js
/**
* Normalizes Oura V2 sleep data.
* @param {Object} ouraData - The raw data object from the Oura API's sleep endpoint.
* @returns {UnifiedSleepRecord}
*/
export function normalizeOuraSleep(ouraData) {
return {
source: 'oura',
date: ouraData.day,
totalSleepSeconds: ouraData.deep + ouraData.rem + ouraData.light,
deepSleepSeconds: ouraData.deep,
lightSleepSeconds: ouraData.light,
remSleepSeconds: ouraData.rem,
awakeSeconds: ouraData.awake,
sleepScore: ouraData.score,
};
}
/**
* Normalizes Garmin Health API sleep data.
* @param {Object} garminData - The raw data object from the Garmin API.
* @returns {UnifiedSleepRecord}
*/
export function normalizeGarminSleep(garminData) {
return {
source: 'garmin',
date: garminData.calendarDate,
totalSleepSeconds: garminData.deepSleepDurationInSeconds + garminData.lightSleepDurationInSeconds + garminData.remSleepInSeconds,
deepSleepSeconds: garminData.deepSleepDurationInSeconds,
lightSleepSeconds: garminData.lightSleepDurationInSeconds,
remSleepSeconds: garminData.remSleepInSeconds,
awakeSeconds: garminData.awakeDurationInSeconds,
sleepScore: garminData.sleepScores?.overall?.value, // Path may vary
};
}
Step 3: Setting Up Webhook Listeners for Real-Time Updates
What we're doing
Instead of constantly polling the Garmin and Oura APIs for new data (which is inefficient and risks hitting rate limits), we can use webhooks. Both services will send a POST request to an endpoint we specify whenever new data is available.
A critical part of handling webhooks is verifying the signature of every incoming request to ensure it genuinely came from the expected source (e.g., Oura) and not a malicious actor.
Implementation
We'll create a dynamic API route to handle incoming webhooks from both providers.
// pages/api/webhooks/[provider].js
import { buffer } from 'micro';
import crypto from 'crypto';
// This is crucial for signature verification
export const config = {
api: {
bodyParser: false,
},
};
const handler = async (req, res) => {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).end('Method Not Allowed');
}
const { provider } = req.query;
const rawBody = await buffer(req);
try {
if (provider === 'oura') {
const signature = req.headers['x-oura-signature'];
const secret = process.env.OURA_WEBHOOK_SECRET; // A secret you configure in Oura's dashboard
const isValid = verifyOuraSignature(signature, rawBody, secret);
if (!isValid) {
return res.status(401).json({ message: 'Invalid signature' });
}
console.log('Oura webhook verified. Processing...');
// TODO: Process the webhook payload, fetch new data, normalize, and save to DB.
} else if (provider === 'garmin') {
// Garmin webhook verification logic would go here.
// It typically involves a similar HMAC-SHA256 signature check.
console.log('Garmin webhook received. Processing...');
}
res.status(200).json({ message: 'Webhook received' });
} catch (error) {
res.status(500).json({ message: `Webhook error: ${error.message}` });
}
};
/**
* Verifies the HMAC signature from Oura.
* @param {string} signatureHeader - The value from the 'x-oura-signature' header.
* @param {Buffer} body - The raw request body.
* @param {string} secret - Your Oura application's client secret.
* @returns {boolean}
*/
function verifyOuraSignature(signatureHeader, body, secret) {
if (!signatureHeader) return false;
const hmac = crypto.createHmac('sha256', secret);
const digest = Buffer.from(hmac.update(body).digest('hex'), 'utf8');
const signature = Buffer.from(signatureHeader, 'utf8');
return crypto.timingSafeEqual(digest, signature);
}
export default handler;
How it works
- You configure a webhook subscription in your Oura and Garmin developer dashboards, pointing to
https://your-domain.com/api/webhooks/ouraand.../garminrespectively. - When a user syncs their device, the provider sends a
POSTrequest to your endpoint. - Our API route first grabs the raw request body. This is essential, as even a single byte change from parsing will invalidate the signature.
- We compute our own HMAC-SHA256 signature using the raw body and our secret key.
- We use
crypto.timingSafeEqualto compare our computed signature with the one in the request header. This prevents timing attacks. - If valid, we can safely process the event, which usually involves using the user's stored
access_tokento fetch the latest data, normalizing it with our functions from Step 2, and updating our database.
Putting It All Together: The Unified Dashboard API
Now that we have authentication, normalization, and data updates handled, we can create the final API endpoint that our frontend will use.
// pages/api/dashboard/sleep.js
import { getSession } from 'next-auth/react';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default async function handler(req, res) {
const session = await getSession({ req });
if (!session) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Here you would fetch the normalized sleep data you've stored
// in your own database for the logged-in user.
const userSleepData = await prisma.unifiedSleepRecord.findMany({
where: {
userId: session.user.id,
},
orderBy: {
date: 'desc',
},
take: 30, // Get the last 30 days
});
res.status(200).json(userSleepData);
}
This API route is clean, secure, and performant. The frontend's job is now reduced to fetching from /api/dashboard/sleep and rendering the unified data, with no knowledge of the complex machinery running behind the scenes.
Security Best Practices
- Token Management: Never expose access or refresh tokens to the client. Store them encrypted in your server-side database.
- Webhook Verification: Always verify the signature of every incoming webhook request before processing its payload.
- Environment Variables: Use
.env.localto store all secrets and keys. Never commit this file to version control. - Server-Side Validation: Always validate user sessions and permissions on the server for every API request that accesses or modifies data.
Conclusion
By leveraging Next.js API Routes as a Backend for Frontend, we've designed a robust and scalable architecture for aggregating data from multiple wearable APIs. This pattern effectively isolates complexity, enhances security, and provides a delightful developer experience for the frontend team.
We successfully tackled three core challenges: securely managing multi-provider OAuth 2.0 flows, normalizing inconsistent data schemas, and processing real-time updates via webhooks. This architectural blueprint can be extended to include other data sources like Apple Health, Fitbit, or Whoop, creating a truly unified health dashboard.