diff --git a/apps/vdn-static/.env.example b/apps/vdn-static/.env.example
new file mode 100644
index 0000000..6256a2a
--- /dev/null
+++ b/apps/vdn-static/.env.example
@@ -0,0 +1,6 @@
+# Drupal API Configuration
+# Use the CORS proxy for local development
+VITE_DRUPAL_API_URL=/api/drupal-proxy
+
+# For direct access (requires CORS on Drupal)
+# VITE_DRUPAL_API_URL=https://cms.viossa.net/jsonapi
\ No newline at end of file
diff --git a/apps/vdn-static/src/services/DrupalService.ts b/apps/vdn-static/src/services/DrupalService.ts
new file mode 100644
index 0000000..c8f75ff
--- /dev/null
+++ b/apps/vdn-static/src/services/DrupalService.ts
@@ -0,0 +1,256 @@
+///
+/**
+ * Drupal JSON:API Service
+ * Provides typed access to Drupal content via JSON:API
+ */
+
+import type {
+ JsonApiParams,
+ JsonApiDocument,
+ JsonApiResource,
+ JsonApiLinks,
+ JsonApiError,
+ DrupalNodeAttributes,
+ DrupalTaxonomyTermAttributes,
+} from "../types/drupal";
+
+type ContentType = "article" | "page" | "learning_resource";
+type Vocabulary = "tags" | "categories";
+
+interface FetchOptions {
+ baseUrl?: string;
+ useProxy?: boolean;
+}
+
+interface NodeListParams extends JsonApiParams {
+ contentType: ContentType;
+}
+
+interface NodeParams extends JsonApiParams {
+ id: string;
+ contentType: ContentType;
+ include?: string[];
+}
+
+interface TaxonomyParams {
+ vocabulary: Vocabulary;
+}
+
+/**
+ * DrupalService - Client for Drupal JSON:API
+ *
+ * Supports two modes:
+ * 1. Direct: https://cms.viossa.net/jsonapi (requires CORS)
+ * 2. Proxy: /api/drupal-proxy/ (via viossa.net nginx)
+ *
+ * Default uses proxy to avoid CORS issues.
+ */
+export class DrupalService {
+ private baseUrl: string;
+
+ constructor(options: FetchOptions = {}) {
+ // Use environment variable or default to proxy path
+ const envUrl = import.meta.env.VITE_DRUPAL_API_URL;
+
+ if (options.baseUrl) {
+ this.baseUrl = options.baseUrl;
+ } else if (envUrl) {
+ this.baseUrl = envUrl;
+ } else if (options.useProxy === false) {
+ // Direct access to Drupal
+ this.baseUrl = "https://cms.viossa.net/jsonapi";
+ } else {
+ // Default: use proxy
+ this.baseUrl = "/api/drupal-proxy";
+ }
+ }
+
+ /**
+ * Build query string from JsonApiParams
+ */
+ private buildQueryString(params: JsonApiParams = {}): string {
+ const queryParts: string[] = [];
+
+ // Include relationships
+ if (params.include?.length) {
+ queryParts.push(`include=${params.include.join(",")}`);
+ }
+
+ // Sparse fieldsets
+ if (params.fields) {
+ const fields = Object.entries(params.fields)
+ .map(([type, fields]) => `fields[${type}]=${fields.join(",")}`)
+ .join("&");
+ queryParts.push(fields);
+ }
+
+ // Sorting
+ if (params.sort?.length) {
+ queryParts.push(`sort=${params.sort.join(",")}`);
+ }
+
+ // Pagination
+ if (params.page) {
+ if (params.page.limit) {
+ queryParts.push(`page[limit]=${params.page.limit}`);
+ }
+ if (params.page.offset) {
+ queryParts.push(`page[offset]=${params.page.offset}`);
+ }
+ if (params.page.cursor) {
+ queryParts.push(`page[cursor]=${params.page.cursor}`);
+ }
+ }
+
+ // Filtering
+ if (params.filter) {
+ const buildFilter = (obj: Record, prefix = "filter"): string[] => {
+ const parts: string[] = [];
+ for (const [key, value] of Object.entries(obj)) {
+ const paramKey = `${prefix}[${key}]`;
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
+ parts.push(...buildFilter(value as Record, paramKey));
+ } else if (Array.isArray(value)) {
+ parts.push(`${paramKey}=${value.join(",")}`);
+ } else {
+ parts.push(`${paramKey}=${value}`);
+ }
+ }
+ return parts;
+ };
+ queryParts.push(...buildFilter(params.filter));
+ }
+
+ return queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
+ }
+
+ /**
+ * Fetch wrapper with error handling
+ */
+ private async fetchJson(url: string): Promise {
+ const response = await fetch(url, {
+ headers: {
+ "Accept": "application/vnd.api+json",
+ },
+ });
+
+ if (!response.ok) {
+ const errorBody = await response.text();
+ let errors: JsonApiError[] = [];
+
+ try {
+ const errorJson = JSON.parse(errorBody);
+ errors = errorJson.errors || [];
+ } catch {
+ // NotJSON error
+ }
+
+ throw new DrupalApiError(
+ `HTTP ${response.status}: ${response.statusText}`,
+ response.status,
+ errors
+ );
+ }
+
+ return response.json();
+ }
+
+ /**
+ * Get nodes by content type
+ */
+ async getNodes(
+ params: NodeListParams
+ ): Promise> {
+ const { contentType, ...queryParams } = params;
+ const queryString = this.buildQueryString(queryParams);
+ const url = `${this.baseUrl}/node/${contentType}${queryString}`;
+
+ return this.fetchJson>(url);
+ }
+
+ /**
+ * Get a single node by ID
+ * Note: Drupal JSON:API uses /jsonapi/node/{type}/{uuid} for individual nodes
+ */
+ async getNode(
+ params: NodeParams
+ ): Promise> {
+ const { id, contentType, include } = params;
+ const queryParams: JsonApiParams = include ? { include } : {};
+ const queryString = this.buildQueryString(queryParams);
+ const url = `${this.baseUrl}/node/${contentType}/${id}${queryString}`;
+
+ return this.fetchJson>(url);
+ }
+
+ /**
+ * Get taxonomy terms by vocabulary
+ */
+ async getTaxonomyTerms(
+ params: TaxonomyParams
+ ): Promise> {
+ const { vocabulary } = params;
+ const url = `${this.baseUrl}/taxonomy_term/${vocabulary}`;
+
+ return this.fetchJson>(url);
+ }
+
+ /**
+ * Follow pagination links
+ */
+ async getNextPage(links: JsonApiLinks): Promise | null> {
+ if (!links.next) return null;
+
+ // Handle both string and object link formats
+ const nextUrl = typeof links.next === "string" ? links.next : links.next;
+ if (!nextUrl) return null;
+
+ // If relative link, prepend base URL
+ const url = nextUrl.startsWith("/") ? `${this.baseUrl}${nextUrl}` : nextUrl;
+
+ return this.fetchJson>(url);
+ }
+
+ /**
+ * Helper: Extract data array from document
+ */
+ extractData(document: JsonApiDocument): JsonApiResource[] {
+ return Array.isArray(document.data) ? document.data : [document.data];
+ }
+
+ /**
+ * Helper: Get attribute from resource
+ */
+ getAttribute(resource: JsonApiResource): T {
+ return resource.attributes;
+ }
+
+ /**
+ * Helper: Check for errors in response
+ */
+ hasErrors(document: JsonApiDocument): boolean {
+ return document.errors !== undefined && document.errors.length > 0;
+ }
+}
+
+/**
+ * Custom error class for Drupal API errors
+ */
+export class DrupalApiError extends Error {
+ status: number;
+ errors: JsonApiError[];
+
+ constructor(
+ message: string,
+ status: number,
+ errors: JsonApiError[] = []
+ ) {
+ super(message);
+ this.name = "DrupalApiError";
+ this.status = status;
+ this.errors = errors;
+ }
+}
+
+// Export singleton instance with default configuration
+export const drupalService = new DrupalService();
\ No newline at end of file
diff --git a/apps/vdn-static/src/types/drupal.ts b/apps/vdn-static/src/types/drupal.ts
new file mode 100644
index 0000000..d66caca
--- /dev/null
+++ b/apps/vdn-static/src/types/drupal.ts
@@ -0,0 +1,115 @@
+/**
+ * TypeScript interfaces for Drupal JSON:API responses.
+ * Based on JSON:API specification 1.1
+ */
+
+// JSON:API Resource Object
+export interface JsonApiResource> {
+ type: string;
+ id: string;
+ attributes: T;
+ relationships?: Record;
+ links?: JsonApiLinks;
+}
+
+// JSON:API Relationship Object
+export interface JsonApiRelationship {
+ data: JsonApiResourceIdentifier | JsonApiResourceIdentifier[];
+ links?: JsonApiLinks;
+}
+
+// JSON:API Resource Identifier
+export interface JsonApiResourceIdentifier {
+ type: string;
+ id: string;
+}
+
+// JSON:API Links Object
+export interface JsonApiLinks {
+ self?: string;
+ next?: string;
+ prev?: string;
+ first?: string;
+ last?: string;
+ [key: string]: string | undefined;
+}
+
+// JSON:API Document (Top-level response)
+export interface JsonApiDocument> {
+ jsonapi?: { version: string; meta?: Record };
+ data: JsonApiResource | JsonApiResource[];
+ included?: JsonApiResource[];
+ links?: JsonApiLinks;
+ meta?: Record;
+ errors?: JsonApiError[];
+}
+
+// JSON:API Error Object
+export interface JsonApiError {
+ id?: string;
+ status?: string;
+ code?: string;
+ title?: string;
+ detail?: string;
+ source?: { pointer?: string; parameter?: string };
+ links?: JsonApiLinks;
+}
+
+// JSON:API Query Parameters
+export interface JsonApiParams {
+ include?: string[];
+ fields?: Record;
+ sort?: string[];
+ page?: { limit?: number; offset?: number; cursor?: string };
+ filter?: Record;
+}
+
+// Drupal Node Attributes (Base)
+export interface DrupalNodeAttributes {
+ title: string;
+ created: string;
+ changed: string;
+ status: boolean;
+ sticky: boolean;
+ promote: boolean;
+ path?: { alias: string; pid: number; langcode: string };
+ body?: {
+ value: string;
+ format: string;
+ summary: string;
+ processed: string;
+ };
+}
+
+// Drupal Taxonomy Term Attributes
+export interface DrupalTaxonomyTermAttributes {
+ name: string;
+ description?: { value: string; format: string; processed: string };
+ weight: number;
+ parent?: JsonApiResourceIdentifier[];
+}
+
+// Content Type Specific Interfaces
+export interface ArticleNodeAttributes extends DrupalNodeAttributes {
+ // Article-specific fields can be added here
+}
+
+export interface PageNodeAttributes extends DrupalNodeAttributes {
+ // Page-specific fields can be added here
+}
+
+export interface LearningResourceNodeAttributes extends DrupalNodeAttributes {
+ // Learning resource-specific fields can be added here
+}
+
+// Typed Response Helpers
+export type ArticleNode = JsonApiResource;
+export type PageNode = JsonApiResource;
+export type LearningResourceNode = JsonApiResource;
+
+export type TaxonomyTerm = JsonApiResource;
+
+// Collection Response Types
+export type NodeList> = JsonApiDocument;
+export type SingleNode> = JsonApiDocument;
+export type TaxonomyTermList = JsonApiDocument;
\ No newline at end of file
diff --git a/apps/vdn-static/src/vite-env.d.ts b/apps/vdn-static/src/vite-env.d.ts
index dabd0de..48fef1e 100644
--- a/apps/vdn-static/src/vite-env.d.ts
+++ b/apps/vdn-static/src/vite-env.d.ts
@@ -1,2 +1,10 @@
///
///
+
+interface ImportMetaEnv {
+ readonly VITE_DRUPAL_API_URL?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
\ No newline at end of file