othmanadi/openui-forge · Archived

openui-forge-java

OpenUI generative UI with a Java Spring Boot (WebFlux) backend. Streams the OpenAI API directly via WebClient as SSE.

First seen Jun 20, 2026

Installation

$ npx skills add othmanadi/openui-forge --skill openui-forge-java

Stronger alternatives

This repository is archived — consider an actively maintained alternative.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from othmanadi/openui-forge · top by installs.

npx skills add othmanadi/openui-forge

Browse all from othmanadi/openui-forge

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 22
License LICENSE
Default branch main
Open issues 0
Status Archived

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.2.0

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 11,568 B
  • docs SUMMARY.md 142 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 2 installs

SKILL.md

OpenUI Forge — Java

Build generative UI apps with a React frontend + Java Spring Boot (WebFlux) backend. The backend forwards OpenAI's SSE stream to the browser via a reactive WebClient and a Flux<String> controller, pairing with openAIAdapter() on the frontend.

Activation Triggers

  • "openui java", "openui spring", "openui spring boot backend"
  • "generative ui java", "java streaming ui backend", "webflux openui"

Prerequisites

  • Node.js >= 22 (24 LTS recommended) + React >= 18.3.1 (19+ recommended) (frontend)
  • Java 21 LTS recommended (Spring Boot 4.x requires Java 17+; it builds against Java up to 26)
  • Maven 3.9+ (or Gradle 8+)
  • Spring Boot 4.0.x (current stable line; 4.1.x is the latest). Boot 3.5.x also works but reached end of OSS support on 2026-06-30.
  • OPENAIAPIKEY environment variable set

Quick Start

  1. Create the React frontend and install OpenUI deps:
npm install @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang lucide-react zod
  1. Generate the system prompt into the backend's resources:
npx @openuidev/cli generate ./src/lib/library.ts --out backend/src/main/resources/system-prompt.txt
  1. Create the Spring Boot backend (see Full Code below)
  2. Run: mvn spring-boot:run on :8080, frontend on :3000

Full Code

Backend: backend/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.0.6</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>openui-backend</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <!-- WebFlux pulls in the reactive web stack, WebClient, and Reactor Netty -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Backend: backend/src/main/resources/application.properties

# Server port (override with SERVER_PORT env var). Frontend expects 8080.
server.port=${SERVER_PORT:8080}

Backend: backend/src/main/java/com/example/openui/Application.java

package com.example.openui;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.config.CorsRegistry;
import org.springframework.web.reactive.config.WebFluxConfigurer;
import org.springframework.web.reactive.function.client.WebClient;

import reactor.core.publisher.Flux;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    // CORS — lock to the configured frontend origin. Never use "*" here: the
    // endpoint is browser-callable and a wildcard lets any site burn your key.
    @Bean
    public WebFluxConfigurer corsConfigurer(
            @Value("${FRONTEND_ORIGIN:http://localhost:3000}") String frontendOrigin) {
        return new WebFluxConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/api/**")
                        .allowedOrigins(frontendOrigin)
                        .allowedMethods("POST", "OPTIONS")
                        .allowedHeaders("Content-Type")
                        .allowCredentials(true);
            }
        };
    }

    // Shared non-blocking WebClient (Reactor Netty). Honors OPENAI_BASE_URL so
    // OpenAI-compatible providers (Azure, Groq, Together, Ollama, ...) work.
    @Bean
    public WebClient openAiWebClient(
            WebClient.Builder builder,
            @Value("${OPENAI_BASE_URL:https://api.openai.com/v1}") String baseUrl) {
        return builder.baseUrl(baseUrl).build();
    }
}

record ChatMessage(String role, String content) {}

record ChatRequest(List<ChatMessage> messages) {}

@RestController
class ChatController {

    private final WebClient openAiWebClient;
    private final String apiKey;
    private final String model;
    private final String systemPrompt;

    ChatController(
            WebClient openAiWebClient,
            @Value("${OPENAI_API_KEY:}") String apiKey,
            @Value("${OPENAI_MODEL:gpt-5.5}") String model,
            @Value("classpath:system-prompt.txt") Resource systemPromptResource) {
        this.openAiWebClient = openAiWebClient;
        this.apiKey = apiKey;
        this.model = model;
        try {
            // Loaded ONCE at startup from src/main/resources/system-prompt.txt.
            this.systemPrompt = systemPromptResource.getContentAsString(StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new UncheckedIOException(
                    "Failed to read system-prompt.txt from classpath. Generate it with: "
                    + "npx @openuidev/cli generate ./src/lib/library.ts "
                    + "--out src/main/resources/system-prompt.txt", e);
        }
    }

    // produces=text/event-stream + Flux<String>: Spring's SSE writer wraps each
    // emitted String as "data: <element>\n\n". We emit BARE JSON payloads (upstream
    // "data:" prefix already stripped by the SSE reader) so the wire output is
    // exactly "data: {chunk}\n\n" ... "data: [DONE]\n\n" — what openAIAdapter() expects.
    @PostMapping(value = "/api/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    Flux<String> chat(@RequestBody ChatRequest request) {
        if (apiKey == null || apiKey.isBlank()) {
            return Flux.just("{\"error\":\"OPENAI_API_KEY not set\"}");
        }

        // Prepend the system prompt server-side; never trust a client system message.
        List<ChatMessage> messages = new ArrayList<>();
        messages.add(new ChatMessage("system", systemPrompt));
        if (request.messages() != null) {
            messages.addAll(request.messages());
        }

        Map<String, Object> upstreamBody = Map.of(
                "model", model,
                "stream", true,
                "messages", messages);

        // bodyToFlux(String.class) on a text/event-stream response makes Spring's
        // ServerSentEventHttpMessageReader parse upstream "data:" events and emit
        // each event's bare payload incrementally (buffered across TCP chunks).
        return openAiWebClient.post()
                .uri("/chat/completions")
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.TEXT_EVENT_STREAM)
                .bodyValue(upstreamBody)
                .retrieve()
                .bodyToFlux(String.class);
    }
}

Frontend: app/chat/page.tsx

"use client";
import { FullScreen } from "@openuidev/react-ui";
import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
import {
  openAIAdapter,
  openAIMessageFormat,
} from "@openuidev/react-headless";

export default function ChatPage() {
  return (
    <FullScreen
      componentLibrary={openuiChatLibrary}
      streamProtocol={openAIAdapter()}
      messageFormat={openAIMessageFormat}
      apiUrl="http://localhost:8080/api/chat"
    />
  );
}

The Spring Boot backend forwards OpenAI's SSE stream incrementally: WebClient.bodyToFlux(String.class) parses upstream data: events as they arrive, and the Flux<String> controller (produces=text/event-stream) re-frames each payload as data: {chunk}\n\n, ending with data: [DONE]\n\n. Reactor is fully non-blocking, so tokens flush to the client as they stream (no buffering of the whole response). Pair it with openAIAdapter() on the frontend. openAIReadableStreamAdapter() is for NDJSON (no data: prefix) and will silently produce no output here.

Because the controller emits Flux<String> with text/event-stream, Spring's ServerSentEventHttpMessageWriter adds the data: prefix for you. Do not prepend data: to the strings yourself or you will get a doubled data: data: {...} frame that the adapter cannot parse.

System Prompt Generation

npx @openuidev/cli generate ./src/lib/library.ts --out backend/src/main/resources/system-prompt.txt

Validation Checklist

  • system-prompt.txt exists in backend/src/main/resources/
  • OPENAIAPIKEY is set in the environment
  • OPENAIBASEURL set if using an OpenAI-compatible provider (default https://api.openai.com/v1)
  • CORS allows the frontend origin (FRONTEND_ORIGIN, default http://localhost:3000), not a wildcard
  • Controller is produces = MediaType.TEXTEVENTSTREAM_VALUE and returns Flux<String>
  • Emitted strings are bare JSON (no manual data: prefix — Spring adds it)
  • Frontend apiUrl points to http://localhost:8080/api/chat
  • Frontend uses streamProtocol={openAIAdapter()} and openAIMessageFormat
  • componentLibrary={openuiChatLibrary} prop passed to FullScreen
  • CSS import in root layout (@openuidev/react-ui/components.css)

Error Patterns

Error Cause Fix
CORS blocked Origin mismatch Set FRONTEND_ORIGIN to the frontend URL; check allowedOrigins
FileNotFoundException / UncheckedIOException on startup system-prompt.txt missing from src/main/resources/ Run the CLI generate command into that path, then rebuild
401 from upstream OPENAIAPIKEY unset or invalid Export a valid key; verify OPENAIBASEURL matches the provider
WebClientResponseException 4xx/5xx Bad model or provider error Check OPENAIMODEL and OPENAIBASE_URL; inspect the exception body
Doubled data: data: frames Manually prefixed strings with data: Emit bare JSON; the SSE writer adds the prefix
Empty render / no output Frontend used openAIReadableStreamAdapter() (NDJSON) Use openAIAdapter() to match the SSE body
Response buffers then dumps at once Returned Mono/collected the Flux Return the Flux<String> directly so elements stream as they arrive