{
  "slug": "consume-java",
  "title": "Consume from Java (Spring Boot)",
  "description": "Read @webhouse/cms content from a Spring Boot application. Reader class, controllers, Thymeleaf templates.",
  "category": "consumers",
  "order": 9,
  "locale": "en",
  "translationGroup": "0637c1f3-3604-480d-9cf8-b097e833c3b3",
  "helpCardId": null,
  "content": "## Setup\n\nPlace your Spring Boot project and @webhouse/cms content side by side:\n\n```\nmy-project/\n  cms.config.ts          # Schema (used by CMS admin)\n  content/               # JSON documents (read by Spring Boot)\n  public/uploads/        # Media files\n  pom.xml\n  src/main/java/...\n  src/main/resources/templates/\n```\n\n## Reader class\n\nCreate `src/main/java/app/webhouse/cmsreader/WebhouseReader.java`:\n\n```java\npackage app.webhouse.cmsreader;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Stream;\n\npublic final class WebhouseReader {\n    private static final Pattern SAFE_NAME = Pattern.compile(\"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$\");\n\n    private final Path contentDir;\n    private final ObjectMapper mapper = new ObjectMapper();\n\n    public WebhouseReader(String contentDir) {\n        this.contentDir = Path.of(contentDir).toAbsolutePath().normalize();\n    }\n\n    public List<WebhouseDocument> collection(String collection, String locale) {\n        validateName(collection);\n        Path dir = contentDir.resolve(collection);\n        if (!Files.isDirectory(dir)) return List.of();\n\n        List<WebhouseDocument> docs = new ArrayList<>();\n        try (Stream<Path> stream = Files.list(dir)) {\n            stream.filter(p -> p.getFileName().toString().endsWith(\".json\")).forEach(p -> {\n                try {\n                    WebhouseDocument doc = mapper.readValue(p.toFile(), WebhouseDocument.class);\n                    if (!\"published\".equals(doc.status)) return;\n                    if (locale != null && !locale.equals(doc.locale)) return;\n                    docs.add(doc);\n                } catch (IOException ignored) { /* skip malformed */ }\n            });\n        } catch (IOException e) {\n            return List.of();\n        }\n        docs.sort(Comparator.comparing(\n            (WebhouseDocument d) -> d.getStringOr(\"date\", \"\"),\n            Comparator.reverseOrder()\n        ));\n        return docs;\n    }\n\n    public Optional<WebhouseDocument> document(String collection, String slug) {\n        validateName(collection);\n        validateName(slug);\n        Path file = contentDir.resolve(collection).resolve(slug + \".json\");\n        if (!file.startsWith(contentDir) || !Files.isRegularFile(file)) {\n            return Optional.empty();\n        }\n        try {\n            WebhouseDocument doc = mapper.readValue(file.toFile(), WebhouseDocument.class);\n            return \"published\".equals(doc.status) ? Optional.of(doc) : Optional.empty();\n        } catch (IOException e) {\n            return Optional.empty();\n        }\n    }\n\n    public Optional<WebhouseDocument> findTranslation(WebhouseDocument doc, String collection) {\n        if (doc.translationGroup == null) return Optional.empty();\n        return collection(collection, null).stream()\n            .filter(o -> doc.translationGroup.equals(o.translationGroup))\n            .filter(o -> doc.locale == null || !doc.locale.equals(o.locale))\n            .findFirst();\n    }\n\n    private static void validateName(String name) {\n        if (name == null || !SAFE_NAME.matcher(name).matches()) {\n            throw new IllegalArgumentException(\"Invalid name: \" + name);\n        }\n    }\n}\n```\n\nAnd the document type:\n\n```java\npackage app.webhouse.cmsreader;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport java.util.Map;\n\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class WebhouseDocument {\n    public String id;\n    public String slug;\n    public String status;\n    public String locale;\n    public String translationGroup;\n    public Map<String, Object> data;\n\n    public String getString(String key) {\n        if (data == null) return null;\n        Object v = data.get(key);\n        return v instanceof String ? (String) v : null;\n    }\n\n    public String getStringOr(String key, String fallback) {\n        String v = getString(key);\n        return v != null ? v : fallback;\n    }\n}\n```\n\n## Spring configuration\n\nRegister the reader as a bean in your `Application.java`:\n\n```java\n@SpringBootApplication\npublic class Application {\n    public static void main(String[] args) {\n        SpringApplication.run(Application.class, args);\n    }\n\n    @Bean\n    public WebhouseReader webhouseReader() {\n        return new WebhouseReader(\"content\");\n    }\n}\n```\n\n## Controller\n\n```java\n@Controller\npublic class BlogController {\n    private final WebhouseReader cms;\n\n    public BlogController(WebhouseReader cms) {\n        this.cms = cms;\n    }\n\n    @GetMapping(\"/\")\n    public String home(Model model) {\n        model.addAttribute(\"posts\", cms.collection(\"posts\", \"en\"));\n        return \"home\";\n    }\n\n    @GetMapping(\"/blog/{slug}\")\n    public String post(@PathVariable String slug, Model model) {\n        WebhouseDocument post = cms.document(\"posts\", slug)\n            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));\n        model.addAttribute(\"post\", post);\n        return \"post\";\n    }\n}\n```\n\n## Thymeleaf template\n\n```html\n<!-- src/main/resources/templates/post.html -->\n<!DOCTYPE html>\n<html xmlns:th=\"http://www.thymeleaf.org\">\n<head>\n    <title th:text=\"${post.getString('title')}\">Post</title>\n</head>\n<body>\n    <article>\n        <h1 th:text=\"${post.getString('title')}\">Title</h1>\n        <time th:text=\"${post.getStringOr('date', '')}\">date</time>\n        <div th:utext=\"${contentHtml}\">content</div>\n    </article>\n</body>\n</html>\n```\n\n## Markdown rendering\n\nUse `commonmark-java` for richtext fields. Add to `pom.xml`:\n\n```xml\n<dependency>\n    <groupId>org.commonmark</groupId>\n    <artifactId>commonmark</artifactId>\n    <version>0.24.0</version>\n</dependency>\n```\n\nThen create a service:\n\n```java\n@Service\npublic class MarkdownService {\n    private final Parser parser = Parser.builder().build();\n    private final HtmlRenderer renderer = HtmlRenderer.builder().build();\n\n    public String render(String markdown) {\n        return renderer.render(parser.parse(markdown != null ? markdown : \"\"));\n    }\n}\n```\n\nInject it into your controller and pass `markdownService.render(post.getString(\"content\"))` as `contentHtml`.\n\n## Serving uploaded media\n\nIn `application.properties`:\n\n```properties\nspring.web.resources.static-locations=classpath:/static/,file:public/\n```\n\nNow `/uploads/my-image.jpg` is served from `public/uploads/my-image.jpg`.\n\n## i18n: reading both locales\n\n```java\n// All Danish posts\nList<WebhouseDocument> daPosts = cms.collection(\"posts\", \"da\");\n\n// Find the translation of a specific post\nWebhouseDocument post = cms.document(\"posts\", \"hello-world\").orElseThrow();\nWebhouseDocument translation = cms.findTranslation(post, \"posts\").orElse(null);\n```\n\n## Caching\n\nUse Spring's `@Cacheable`:\n\n```java\n@Service\npublic class CachedWebhouse {\n    private final WebhouseReader reader;\n\n    @Cacheable(\"posts\")\n    public List<WebhouseDocument> posts(String locale) {\n        return reader.collection(\"posts\", locale);\n    }\n}\n```\n\nInvalidate via CMS admin webhook when content changes.\n\n## Production deployment\n\n- **Fly.io / Render / Heroku** — Spring Boot apps work out of the box\n- **Bare-metal Tomcat** — change packaging to `war` in pom.xml\n- **Docker** — multi-stage build with `eclipse-temurin:21-jre-alpine`\n- **AWS Beanstalk / Azure App Service** — native Java support\n\n## Future: Maven Central package\n\nIn F125 Phase 2, this reader will be published to Maven Central as `app.webhouse:cms-reader` so you can:\n\n```xml\n<dependency>\n    <groupId>app.webhouse</groupId>\n    <artifactId>cms-reader</artifactId>\n    <version>0.1.0</version>\n</dependency>\n```\n\nFor now, copy the `WebhouseReader.java` and `WebhouseDocument.java` files into your project (~150 lines total).\n\n## Next steps\n\n- See the [Java example](https://github.com/webhousecode/cms/tree/main/examples/consumers/java-spring-blog)\n- Learn about [Framework-Agnostic Architecture](/docs/framework-agnostic)",
  "excerpt": "Setup\n\nPlace your Spring Boot project and @webhouse/cms content side by side:\n\n\nmy-project/\n  cms.config.ts           Schema (used by CMS admin)\n  content/                JSON documents (read by Spring Boot)\n  public/uploads/         Media files\n  pom.xml\n  src/main/java/...\n  src/main/resources/tem",
  "seo": {
    "metaTitle": "Consume from Java (Spring Boot) — webhouse.app Docs",
    "metaDescription": "Read @webhouse/cms content from a Spring Boot application. Reader class, controllers, Thymeleaf templates.",
    "keywords": [
      "webhouse",
      "cms",
      "java",
      "spring-boot",
      "thymeleaf",
      "consumer"
    ]
  },
  "createdAt": "2026-04-08T12:00:00.000Z",
  "updatedAt": "2026-04-08T12:00:00.000Z"
}