”Who This Guide Is For
This guide is for full-stack developers integrating wearable APIs into Next.js applications. You should have solid understanding of OAuth 2.0 flows, Next.js App Router, and server-side rendering. If you're building wellness dashboards, wearable integrations, or health data platforms, this guide is for you.
Hook: Health tech is booming, and developers are at the forefront, building personalized wellness applications. Wearables like the Oura Ring provide a treasure trove of data—sleep, readiness, and activity—that can power these experiences. But integrating this data securely and efficiently into a web application can be a complex challenge involving authentication, server-state management, and performance optimization.
What we'll build/learn: In this tutorial, we'll build a Next.js application that securely connects to the Oura Ring API using OAuth 2.0. We will then use the power of TanStack's React Query to fetch, cache, and display a user's sleep, readiness, and activity scores. We'll focus on a server-side rendering (SSR) approach to ensure our data is fresh, secure, and loaded before the page is even sent to the client.
Prerequisites:
- Node.js (v18 or later)
- A foundational understanding of React and Next.js
- An Oura Ring and an active Oura account
- Familiarity with async/await in JavaScript
Why this matters to developers: This guide tackles a real-world problem: securely handling third-party API data in a modern web framework.
”Key Definition: OAuth 2.0 Authorization Code Flow OAuth 2.0 is an industry-standard protocol for authorization that enables applications to obtain limited access to user accounts on an HTTP service. The Authorization Code flow is the most secure OAuth pattern for server-side applications: (1) the app redirects the user to the authorization server, (2) the user grants permission, (3) the server returns a temporary authorization code, (4) the app exchanges this code server-to-server for an access token. This design prevents client-side exposure of tokens and is required by most APIs handling sensitive data like health information. Oura Ring API, Fitbit, and Apple HealthKit all use variations of this flow for security compliance. You'll learn a robust pattern for managing API keys and access tokens on the server, preventing exposure to the client. By mastering React Query with SSR, you'll be able to build high-performance applications that feel incredibly responsive to the user.
Understanding the Problem
Integrating a third-party API like Oura's presents several challenges:
- Authentication: The Oura API uses OAuth 2.0, a powerful but multi-step authentication protocol. We need a secure, server-side flow to handle the exchange of a temporary code for a long-lived access token.
- Security: The Client Secret and user access tokens are highly sensitive. They must never be exposed on the client-side. All API calls to Oura must originate from a secure server environment.
- Data Fetching & State Management: Fetching data introduces boilerplate. We need to manage loading states, error states, and caching to avoid redundant API calls. Doing this manually with
useStateanduseEffectcan quickly become messy. - User Experience: We want to avoid showing loading spinners whenever possible. Data should be present when the page loads, which is a perfect use case for Next.js's server-side rendering capabilities.
Our approach—using Next.js Route Handlers for the OAuth flow and API proxying, combined with React Query for state management and SSR—directly solves these challenges elegantly and efficiently.
Prerequisites
Before we start coding, let's get our Oura Application set up.
-
Create an Oura Account: If you don't have one, sign up at cloud.ouraring.com.
-
Register an OAuth Application:
- Navigate to the API Applications section.
- Click "Create New Application".
- Fill in the details. For the "Redirect URIs", enter
http://localhost:3000/api/auth/callback/oura. This is a crucial step for the OAuth flow. - Once created, you will receive a Client ID and a Client Secret. Keep these safe!
-
Set up Environment Variables:
- Create a file named
.env.localin the root of your Next.js project. - Add your Oura credentials and a secret for encrypting session data.
code# .env.local OURA_CLIENT_ID=your_client_id_here OURA_CLIENT_SECRET=your_client_secret_here NEXT_PUBLIC_OURA_CLIENT_ID=your_client_id_here APP_SECRET='complex-secret-for-session-encryption' NEXT_PUBLIC_APP_URL='http://localhost:3000'Code collapsedNote: We expose the Client ID publicly (
NEXT_PUBLIC_) because it's needed on the client to initiate the login flow. The Client Secret remains securely on the server. - Create a file named
-
Create a new Next.js App and install dependencies:
codenpx create-next-app@latest oura-react-query-app cd oura-react-query-app npm install @tanstack/react-query @tanstack/react-query-devtools iron-sessionCode collapsed@tanstack/react-query: The core library for server state management.@tanstack/react-query-devtools: A helpful tool for debugging React Query's cache.iron-session: A library for creating secure, encrypted HTTPOnly cookies to store our session data (like the Oura access token).
Step 1: Implementing the OAuth 2.0 Flow
Our first major task is to get the user authenticated with Oura. This involves three parts: redirecting the user to Oura, handling the callback, and storing the access token securely.
What we're doing
We'll create two API routes in Next.js:
/api/auth/login/oura: This route will construct the Oura authorization URL and redirect the user to it./api/auth/callback/oura: Oura will redirect the user back here after they approve our app. This route will exchange the receivedcodefor anaccess_tokenand save it in an encrypted cookie.
Implementation
First, let's set up our session management utility.
// lib/session.js
import { getIronSession } from 'iron-session';
const sessionOptions = {
password: process.env.APP_SECRET,
cookieName: 'oura-session',
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
},
};
export function getSession(req, res) {
return getIronSession(req, res, sessionOptions);
}
Now, for the login route. This route simply redirects the user to the Oura consent screen.
// app/api/auth/login/oura/route.js
import { NextResponse } from 'next/server';
export async function GET() {
const authUrl = new URL('https://cloud.ouraring.com/oauth/authorize');
authUrl.searchParams.set('client_id', process.env.NEXT_PUBLIC_OURA_CLIENT_ID);
authUrl.searchParams.set('redirect_uri', `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/oura`);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'daily'); // Requesting daily sleep, activity, and readiness data.
return NextResponse.redirect(authUrl.toString());
}
Finally, the callback route. This is where the magic happens.
// app/api/auth/callback/oura/route.js
import { NextResponse } from 'next/server';
import { getIronSession } from 'iron-session';
export async function GET(request) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
if (!code) {
// Handle error: No code was provided
return NextResponse.redirect(new URL('/?error=oauth_error', request.url));
}
// Exchange code for access token
const tokenResponse = await fetch('https://api.ouraring.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/oura`,
client_id: process.env.OURA_CLIENT_ID,
client_secret: process.env.OURA_CLIENT_SECRET,
}),
});
if (!tokenResponse.ok) {
// Handle error: Failed to get token
console.error('Failed to exchange code for token:', await tokenResponse.text());
return NextResponse.redirect(new URL('/?error=token_exchange_failed', request.url));
}
const tokenData = await tokenResponse.json();
// Encrypt and store the token in a cookie
const session = await getIronSession(request.cookies, new NextResponse().cookies, {
password: process.env.APP_SECRET,
cookieName: 'oura-session'
});
session.accessToken = tokenData.access_token;
await session.save();
// Redirect to the dashboard page
return NextResponse.redirect(new URL('/dashboard', request.url));
}
How it works
The OAuth 2.0 "Authorization Code" flow is a standard for secure authentication.
- Our app redirects the user to Oura's authorization server.
- The user logs into Oura and grants our application permission to access their
dailydata. - Oura redirects the user back to our specified
redirect_uri(/api/auth/callback/oura) with a temporarycode. - Our server-side callback handler receives this
code. It then makes a secure, server-to-server POST request to Oura's/oauth/tokenendpoint, including ourclient_secret. - Oura validates the request and returns an
access_token. - We use
iron-sessionto store this token in an encrypted,httpOnlycookie. This means the token is securely stored and cannot be accessed by client-side JavaScript, mitigating XSS attacks.
Step 2: Fetching Oura Data with React Query
Now that we have an access token, we can fetch the user's data. We'll create another API route to act as a proxy to the Oura API and then call it from our frontend using React Query.
What we're doing
- Create an API route
/api/oura/daily-summarythat reads the access token from the session cookie and fetches data from the official Oura API. - Set up a React Query
QueryClientProviderin our_app.jsorlayout.js. - Use the
useQueryhook on our dashboard page to call our internal API route and display the data.
Implementation
First, the server-side API proxy. This route protects our access token.
// app/api/oura/daily-summary/route.js
import { NextResponse } from 'next/server';
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
export async function GET() {
const session = await getIronSession(cookies(), {
password: process.env.APP_SECRET,
cookieName: 'oura-session',
});
if (!session.accessToken) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Get yesterday's date in YYYY-MM-DD format
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().split('T');
const ouraResponse = await fetch(
`https://api.ouraring.com/v2/usercollection/daily_activity?start_date=${dateStr}&end_date=${dateStr}`,
{
headers: {
'Authorization': `Bearer ${session.accessToken}`,
},
}
);
if (!ouraResponse.ok) {
console.error('Oura API Error:', await ouraResponse.text());
return NextResponse.json({ error: 'Failed to fetch data from Oura' }, { status: ouraResponse.status });
}
const ouraData = await ouraResponse.json();
return NextResponse.json(ouraData);
}
Next, set up the React Query provider. This makes the query client available throughout our app.
// app/providers.js
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export default function Providers({ children }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// app/layout.js
import Providers from './providers';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Finally, let's build the dashboard page that uses useQuery to fetch and display the data.
// app/dashboard/page.js
'use client';
import { useQuery } from '@tanstack/react-query';
const fetchOuraData = async () => {
const res = await fetch('/api/oura/daily-summary');
if (!res.ok) {
throw new Error('Network response was not ok');
}
return res.json();
};
export default function Dashboard() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['ouraDailySummary'],
queryFn: fetchOuraData,
});
if (isLoading) {
return <span>Loading...</span>;
}
if (isError) {
return <span>Error: {error.message}</span>;
}
// Assuming the API returns data in the expected format
const summary = data?.data?.;
if (!summary) {
return <span>No data available for yesterday.</span>
}
return (
<div>
<h1>Your Oura Daily Summary for {summary.day}</h1>
<h2>Readiness Score: {summary.score}</h2>
<ul>
<li>Activity Score: {summary.contributors?.activity_balance}</li>
<li>Sleep Score: {summary.contributors?.recovery_index}</li>
</ul>
</div>
);
}
How it works
useQuery is the magic of React Query. It takes a unique queryKey and a queryFn (an async function that returns our data). React Query automatically handles:
- Caching: If we navigate away and back to this page, the data will be served from the cache instantly while a fresh fetch happens in the background.
- Loading and Error States:
isLoadingandisErrorbooleans simplify our UI logic. - Background Refetching: It can automatically refetch data when the window is refocused, keeping our UI in sync.
Putting It All Together with Server-Side Rendering (SSR)
The client-side fetching approach works, but the user sees a "Loading..." message. We can do better. By using Next.js's getServerSideProps and React Query's prefetching mechanism, we can fetch the data on the server and render the complete HTML.
What we're doing
We will refactor our dashboard page to use a Server Component to fetch the data initially. React Query supports this through a mechanism called "hydration". We pre-fetch on the server, "dehydrate" the cache (turn it into a serializable format), and then "rehydrate" it on the client.
Implementation
// app/dashboard/page.js (Updated for SSR with App Router)
import { QueryClient, dehydrate } from '@tanstack/react-query';
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
import Hydrate from '@/components/Hydrate'; // A simple hydration component
import DashboardClient from './DashboardClient'; // The client component part
// This is our data fetching function, now callable on the server
const fetchOuraData = async (accessToken) => {
if (!accessToken) throw new Error('Not Authenticated');
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().split('T');
const ouraResponse = await fetch(
`https://api.ouraring.com/v2/usercollection/daily_activity?start_date=${dateStr}&end_date=${dateStr}`,
{
headers: { 'Authorization': `Bearer ${accessToken}` },
}
);
if (!ouraResponse.ok) throw new Error('Failed to fetch from Oura API');
return ouraResponse.json();
};
export default async function DashboardPage() {
const queryClient = new QueryClient();
const session = await getIronSession(cookies(), {
password: process.env.APP_SECRET,
cookieName: 'oura-session',
});
// We prefetch the query on the server
await queryClient.prefetchQuery({
queryKey: ['ouraDailySummary'],
queryFn: () => fetchOuraData(session.accessToken),
});
const dehydratedState = dehydrate(queryClient);
return (
<Hydrate state={dehydratedState}>
<DashboardClient />
</Hydrate>
);
}
// components/Hydrate.js
'use client';
import { Hydrate as RQHydrate } from '@tanstack/react-query';
function Hydrate(props) {
return <RQHydrate {...props} />;
}
export default Hydrate;
// app/dashboard/DashboardClient.js (This contains our previous page logic)
'use client';
import { useQuery } from '@tanstack/react-query';
// We now need a client-side fetcher for background updates, but it won't be called on initial load.
const clientFetchOuraData = async () => {
const res = await fetch('/api/oura/daily-summary'); // uses our proxy
if (!res.ok) {
throw new Error('Network response was not ok');
}
return res.json();
};
export default function DashboardClient() {
// This hook will now find the data in the cache immediately on the client!
const { data, isLoading, isError, error } = useQuery({
queryKey: ['ouraDailySummary'],
queryFn: clientFetchOuraData,
});
// This UI logic remains the same
if (isLoading) return <span>Loading...</span>; // You won't see this on first load
if (isError) return <span>Error: {error.message}</span>;
const summary = data?.data?.;
if (!summary) return <span>No data available for yesterday.</span>;
return (
<div>
<h1>Your Oura Daily Summary for {summary.day}</h1>
<h2>Readiness Score: {summary.score}</h2>
<ul>
<li>Activity Score: {summary.contributors?.activity_balance}</li>
<li>Sleep Score: {summary.contributors?.recovery_index}</li>
</ul>
</div>
);
}
How it works
- Server-Side (
DashboardPage): This is now an async Server Component. It gets the session, creates a newQueryClient, and callsqueryClient.prefetchQuery. This fetches the data directly on the server. - Dehydration:
dehydrate(queryClient)serializes the query result and its cache metadata into JSON. - Hydration: We pass this dehydrated state to a special
<Hydrate>component. - Client-Side (
DashboardClient): When the page loads in the browser, the<QueryClientProvider>is rehydrated with this server-fetched data. TheuseQueryhook inDashboardClientsees that data for['ouraDailySummary']already exists in the cache and uses it instantly, avoiding any loading state on the initial render.
Conclusion
We have successfully built a full-featured, secure, and performant integration with the Oura Ring API in a Next.js application.
Summary of achievements:
- We implemented the complete server-side OAuth 2.0 flow to securely authenticate users.
- We used
iron-sessionto store sensitive access tokens in encrypted,httpOnlycookies. - We created a "proxy" API route to handle all communication with the Oura API, keeping our credentials safe.
- We leveraged React Query and
useQueryfor declarative data fetching and powerful state management. - Finally, we used Next.js server-side rendering with React Query's hydration to pre-fetch data, eliminating initial load times and improving SEO.
This powerful pattern is not limited to Oura; you can apply it to any third-party API integration, building robust and delightful user experiences.
Next steps for readers:
- Fetch more data: Explore other Oura API endpoints like sleep periods or workouts.
- Add a "Log Out" button: Create an API route that destroys the session.
- Handle token refresh: The Oura access token will eventually expire. Implement logic to use the
refresh_token(obtained in the initial token exchange) to get a new access token automatically. - Visualize the data: Use a charting library like Chart.js or D3 to create a beautiful health dashboard.
Disclaimer
The health data integration techniques presented in this article are for technical educational purposes only.
Frequently Asked Questions
How do I handle OAuth token expiration with Oura API?
Oura access tokens expire after a configurable period (typically 1 hour). When a token expires, API calls return 401 Unauthorized. The solution is implementing token refresh using the refresh_token received during initial authorization. Store both access_token and refresh_token in your session, and on 401 errors, automatically call the token endpoint with grant_type="refresh_token" to get a new access token. According to OAuth 2.0 best practices, refresh tokens should be stored more securely than access tokens—consider using encrypted server-side sessions rather than cookies for refresh tokens.
Can I use this approach with other wearable APIs?
Yes, this pattern applies to virtually all OAuth-enabled wearable APIs including Fitbit, Apple HealthKit, Google Fit, and WHOOP. The main differences are: (1) OAuth endpoint URLs vary between providers, (2) scopes requested differ by API capabilities, (3) data formats and rate limits vary. Apple HealthKit requires special handling through HealthKit framework rather than REST OAuth. For a unified health data backend, consider building abstraction layers that normalize data from different providers into a common schema—this approach is used by health platforms like Validic and Health gorilla.
What's the difference between SSR and SSG for health data?
Server-Side Rendering (SSR) fetches data on each request, ensuring real-time data but with server processing overhead. Static Site Generation (SSG) builds pages at build time, providing instant loads but stale data. For health dashboards where users expect current metrics, SSR with React Query's revalidation on focus provides the best balance: instant initial HTML with fresh data on page interaction. According to Vercel's analytics, hybrid approaches (SSR + client-side revalidation) show 40% better Time to Interactive (TTI) for dashboard applications compared to pure SSR.
How do I secure the OAuth client secret in production?
Never expose your OAuth client secret to the client—this is a fundamental security principle. In development, .env.local keeps secrets safe. In production: (1) use environment variables injected at build/deploy time, (2) Next.js automatically excludes server-side variables from the client bundle, (3) for Vercel deployments, use their environment variable management, (4) for other hosts, use your CI/CD pipeline's secret management. According to the OWASP Top 10, hardcoded secrets and accidentally exposed API keys remain among the most common security vulnerabilities—implement secret scanning tools like TruffleHog or Gitleaks in your CI pipeline.
The health data integration techniques presented in this article are for technical educational purposes only. This is not medical advice and should not be used for health monitoring or medical diagnosis. Oura Ring data should be interpreted in consultation with qualified healthcare professionals. Always consult qualified healthcare professionals for medical advice.
Resources
- Official Oura API V2 Documentation: Oura Cloud API Docs
- TanStack React Query Docs: React Query SSR
- Next.js Documentation: Route Handlers
- Iron Session on GitHub: iron-session