@davidoost/eva-nextjs-sdk
v0.1.19
Published
Lightweight Next.js SDK for Eva OMS
Readme
@davidoost/eva-nextjs-sdk
Lightweight Next.js SDK for the EVA OMS API.
Fully-typed HTTP client following the same SSR pattern as Supabase. Types are generated into your project against your specific EVA environment — so you always have accurate types regardless of which services your instance has enabled, and you're never blocked by a new SDK release when EVA ships weekly updates.
Installation
npm install @davidoost/eva-nextjs-sdkRequires next >= 15.0.0 as a peer dependency.
Setup
1. Environment variables
# .env.local
NEXT_PUBLIC_EVA_BASE_URL=https://api.euw.your-store.eva-online.cloud
EVA_API_KEY=your-api-key
# Optional — sets EVA-Requested-OrganizationUnitID on every request
EVA_ORGANIZATION_UNIT_ID=1
NEXT_PUBLIC_EVA_ORGANIZATION_UNIT_ID=12. Generate types
Run the generate command once (and again whenever your EVA environment gets updated):
npx @davidoost/eva-nextjs-sdk generateThe command reads NEXT_PUBLIC_EVA_BASE_URL from your environment. To target a different EVA environment without changing your .env.local, pass --base-url:
npx @davidoost/eva-nextjs-sdk generate --base-url https://api.euw.staging.eva-online.cloudThis downloads the eva-apispec generator binary, runs it against your environment, and writes the following into your project:
src/eva/
generated/ ← EVA service types, specific to your environment
eva-services-*/
service-map.ts
server.ts ← typed createClient() and createApiClient()
client.ts ← typed createClient() for Client ComponentsCommit the generated files. Re-run the command when your EVA environment updates.
3. Options
# Custom output directory (default: src/eva)
npx @davidoost/eva-nextjs-sdk generate --output src/lib/evaUsage
Server Components
import { createClient } from '@/eva/server';
import { asProduct } from '@davidoost/eva-nextjs-sdk';
export default async function ProductPage({ params }) {
const client = await createClient();
const response = await client.GetProductDetail({ ProductID: params.id });
const product = asProduct(response.Result);
return <div>{product?.display_value}</div>;
}Server Actions
'use server';
import { createClient } from '@/eva/server';
export async function login(email: string, password: string) {
const client = await createClient();
await client.login({ EmailAddress: email, Password: password });
// session cookies are set automatically
}
export async function logout() {
const client = await createClient();
await client.logout();
}
export async function addToCart(productId: number, quantity: number) {
const client = await createClient();
await client.AddProductToOrder({ ProductID: productId, Quantity: quantity });
}Route Handlers
// app/api/products/route.ts
import { createClient } from '@/eva/server';
export async function GET() {
const client = await createClient();
const data = await client.SearchProducts({ Query: '' });
return Response.json(data);
}Client Components
Session cookies are httpOnly — the browser can't read them. For authenticated mutations prefer Server Actions. For public calls use the browser client directly:
'use client';
import { createClient } from '@/eva/client';
export function SearchBar() {
const handleSearch = async (query: string) => {
const client = createClient();
const results = await client.SearchProducts({ Query: query });
};
}API key client
createApiClient() uses your EVA_API_KEY and has access to all EVA services — including the ~30 that are API-key-only and unavailable via session token:
import { createApiClient } from '@/eva/server';
export async function GET() {
const client = createApiClient();
const data = await client.SomeService({});
return Response.json(data);
}Where things can run
| | Server Component | Server Action | Route Handler | Client Component |
|---|:---:|:---:|:---:|:---:|
| createClient() from eva/server | ✅ | ✅ | ✅ | ❌ |
| createApiClient() from eva/server | ✅ | ✅ | ✅ | ❌ |
| createClient() from eva/client | ❌ | ❌ | ❌ | ✅ |
| client.login() | ❌ | ✅ | ✅ | ❌ |
| client.logout() | ❌ | ✅ | ✅ | ❌ |
login() and logout() write cookies — only allowed in Server Actions and Route Handlers.
Silent token refresh
The server client automatically refreshes the access token when it's missing but a refresh token is present. In a Server Action or Route Handler the new token is written to cookies. In a Server Component the write fails silently — middleware handles persistence on the next navigation.
Middleware
Use checkAuth in middleware.ts to refresh tokens and guard routes. Always return result.response — it carries refreshed cookies to both the browser and Server Components in the same request.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { checkAuth } from '@davidoost/eva-nextjs-sdk';
export async function middleware(request: NextRequest) {
const { valid, refreshed, response } = await checkAuth(request);
if (!valid && !refreshed) {
return NextResponse.redirect(new URL('/login', request.url));
}
return response;
}
export const config = {
matcher: ['/account/:path*', '/checkout/:path*'],
};| Result | Meaning |
|---|---|
| valid: true | Access token present |
| refreshed: true | Token refreshed — new cookies on response |
| valid: false, refreshed: false | No session — redirect to login |
Gating by role
const { valid, refreshed, role, response } = await checkAuth(request, {
requiredRole: 'employee', // 'customer' | 'employee'
});
if (!valid && !refreshed) return NextResponse.redirect(new URL('/login', request.url));
if (role !== 'employee') return NextResponse.redirect(new URL('/403', request.url));
return response;The role is derived from data.User.Type at login and stored in eva_user_role cookie automatically.
Debug mode
Set debug: true on any client to log all requests and responses to the console. Auth tokens are redacted.
const client = await createServerClient({ ..., debug: true });
// or
const client = createApiClient({ debug: true });
// or
const client = createClient({ debug: true });Server-side output appears in your terminal. Browser client output appears in the browser console (requests are also visible in the Network tab since they originate from the browser).
[eva-sdk] POST https://api.euw.store.eva-online.cloud/message/SearchProducts
[eva-sdk] Headers: { EVA-User-Agent: 'eva-nextjs-sdk/0.1.8', EVA-App-Token: '...' }
[eva-sdk] Body: {"Query":"roses"}
[eva-sdk] 200 OK — https://api.euw.store.eva-online.cloud/message/SearchProductsApp token (anonymous sessions)
EVA returns an EVA-App-Token header on responses. The SDK stores it automatically in an eva_app_token cookie and sends it back as an EVA-App-Token request header on every subsequent request — enabling anonymous/guest sessions without any extra code. It is sent alongside the Authorization header when a user is logged in.
| Client | Stores app token | Uses app token as fallback |
|---|---|---|
| createClient() (server) | ✅ via cookieMethods.setAll | ✅ |
| createClient() (browser) | ✅ via document.cookie | ✅ |
| createApiClient() | — | — |
The eva_app_token cookie is non-httpOnly on the browser client so JavaScript can read and write it.
Organization unit
The EVA-Requested-OrganizationUnitID header is resolved with this precedence for each client:
| Client | Precedence |
|---|---|
| createClient() (server) | organizationUnitId option → eva_ou cookie → EVA_ORGANIZATION_UNIT_ID env var |
| createApiClient() | organizationUnitId option → EVA_ORGANIZATION_UNIT_ID env var |
| createClient() (browser) | organizationUnitId option → eva_ou cookie → NEXT_PUBLIC_EVA_ORGANIZATION_UNIT_ID env var |
The eva_ou cookie must be non-httpOnly so the browser client can read it.
Via env var (global default, set once):
EVA_ORGANIZATION_UNIT_ID=1
NEXT_PUBLIC_EVA_ORGANIZATION_UNIT_ID=1Per client instance (overrides env var):
const client = await createServerClient({ ..., organizationUnitId: 456 });Via cookie (useful for letting users switch between org units dynamically):
// Set a non-httpOnly cookie named 'eva_ou'
cookieStore.set('eva_ou', '456', { path: '/' }); // no httpOnlyMultiple EVA environments
When your Next.js app serves multiple EVA environments (e.g. multiple stores or regions), pass baseUrl and namespace to createClient().
namespace is appended to all cookie names, keeping sessions fully isolated between environments:
// store A
const clientA = await createClient({
baseUrl: 'https://api.euw.store-a.eva-online.cloud',
namespace: 'store_a',
});
// store B
const clientB = await createClient({
baseUrl: 'https://api.euw.store-b.eva-online.cloud',
namespace: 'store_b',
});Cookies for store_a: eva_session_store_a, eva_refresh_token_store_a, eva_ou_store_a, etc.
Middleware must use the same namespace:
const { valid, refreshed, response } = await checkAuth(request, {
namespace: 'store_a',
});Per-request options
All service calls accept an optional second argument for request-level overrides:
await client.SearchProducts({ Query: 'roses' }, {
// Add or override specific headers
headers: { 'X-Custom-Header': 'value' },
// Omit the Authorization header (useful for public endpoints)
skipAuthorization: true,
// Omit EVA-Requested-OrganizationUnitID (useful for cross-org calls)
skipOrganizationUnitId: true,
});These options are additive — all other SDK-managed headers (EVA-User-Agent, EVA-App-Token, etc.) pass through unchanged.
The same options work with the explicit call() API:
await client.call(SvcSearchProducts, { Query: 'roses' }, { skipAuthorization: true });Default headers for all requests on a client instance:
const client = await createClient({
defaultHeaders: { 'X-My-Header': 'value' },
});Product types
EVA product data is schema-less at the API level. Use asProduct to cast to a typed interface:
import { asProduct } from '@davidoost/eva-nextjs-sdk';
const response = await client.GetProductDetail({ ProductID: 123 });
const product = asProduct(response.Result);
product?.display_value; // string
product?.display_price; // number
product?.primary_image; // { Url?, Blob?, Name?, Type? }Standard fields on EvaProduct:
| Field | Type | Description |
|---|---|---|
| product_id | number | EVA internal product ID |
| backend_id | string? | External / backend identifier |
| display_value | string | Product name |
| slug | string? | URL-friendly slug |
| barcode | string? | Barcode |
| custom_id | string? | Custom identifier |
| product_type | number? | 1 Simple · 2 Bundle · 4 Configurable · 8 Variant |
| logical_level | number? | 0 root configurable · 1 variant |
| primary_image | object? | { Url, Blob, Name, Type } |
| media | object? | Gallery, swatches, image count |
| category_names | string[]? | Category names |
| category_paths | string[][]? | Full category paths |
| brand_name | string? | Brand display name |
| brand_id | number? | EVA brand ID |
| display_price | number? | Current selling price |
| original_price | number? | List price before discounts |
| discount_percentage | number? | e.g. 25 for 25% off |
Custom product properties
Declare your EVA-configured properties once — all asProduct() calls pick them up automatically:
// eva.d.ts (anywhere in your project)
import '@davidoost/eva-nextjs-sdk';
declare module '@davidoost/eva-nextjs-sdk' {
interface EvaProductExtension {
colour?: string;
size_label?: string;
}
}Importing request/response types
The generated files in src/eva/generated/ export all request and response types for your environment. Import from the relevant assembly:
import type { SearchProducts, SearchProductsResponse } from '@/eva/generated/eva-services-core';
async function search(req: SearchProducts): Promise<SearchProductsResponse> {
const client = await createClient();
return client.SearchProducts(req);
}Explicit call() API
Use the Svc* class directly when you need to reference a service by class rather than by name:
import { SvcGetOrder } from '@/eva/generated/eva-services-core';
const order = await client.call(SvcGetOrder, { ID: 123 });
// With per-request options
const order = await client.call(SvcGetOrder, { ID: 123 }, { skipAuthorization: true });Keeping types up to date
Re-run the generate command whenever your EVA environment gets updated:
npx @davidoost/eva-nextjs-sdk generateThe generator binary is cached in node_modules/.cache/eva-sdk-generator/ and reused on subsequent runs. It auto-updates to the latest eva-apispec release.
Types are environment-specific — each customer's src/eva/generated/ will only contain services enabled for their EVA instance. This means you won't see services in autocomplete that aren't available to you.
