userForm
+@param io.github.wimdeblauwe.htmx.spring.boot.jte.EndpointController endpointController
+
+
+
+
+@if( userForm.isPresent())
+
+ Hello ${userForm.get().userName()}! Your password is: ${userForm.get().password()}
+
+@endif
diff --git a/htmx-spring-boot-jte/src/test/resources/application.properties b/htmx-spring-boot-jte/src/test/resources/application.properties
new file mode 100644
index 00000000..442af358
--- /dev/null
+++ b/htmx-spring-boot-jte/src/test/resources/application.properties
@@ -0,0 +1,2 @@
+gg.jte.developmentMode=true
+gg.jte.templateLocation=htmx-spring-boot-jte/src/test/jte
diff --git a/htmx-spring-boot-thymeleaf/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/endpoint/EndpointController.java b/htmx-spring-boot-thymeleaf/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/endpoint/EndpointController.java
new file mode 100644
index 00000000..0a143724
--- /dev/null
+++ b/htmx-spring-boot-thymeleaf/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/endpoint/EndpointController.java
@@ -0,0 +1,33 @@
+package io.github.wimdeblauwe.htmx.spring.boot.thymeleaf.endpoint;
+
+import io.github.wimdeblauwe.htmx.spring.boot.endpoint.HtmxEndpoint;
+import org.springframework.http.HttpMethod;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.servlet.ModelAndView;
+
+@Controller
+public class EndpointController {
+
+
+ public HtmxEndpoint, ModelAndView> htmxTestEndpoint = new HtmxEndpoint<>(
+ "/test", HttpMethod.GET, this::test);
+
+ private ModelAndView test() {
+ return new ModelAndView("test");
+ }
+
+
+ public record UserForm(
+ String userName, String password) {
+ }
+
+ public HtmxEndpoint createUserEndpoint = new HtmxEndpoint<>(
+ "/createUser",
+ HttpMethod.POST,
+ this::createUser
+ );
+
+ private ModelAndView createUser(UserForm userForm) {
+ return new ModelAndView("test");
+ }
+}
diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/AbstractHtmxEndpoint.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/AbstractHtmxEndpoint.java
new file mode 100644
index 00000000..2bcb1e17
--- /dev/null
+++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/AbstractHtmxEndpoint.java
@@ -0,0 +1,101 @@
+package io.github.wimdeblauwe.htmx.spring.boot.endpoint;
+
+import jakarta.servlet.ServletException;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.lang.NonNull;
+import org.springframework.validation.BindException;
+import org.springframework.web.servlet.ModelAndView;
+import org.springframework.web.servlet.function.*;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+public abstract class AbstractHtmxEndpoint implements RouterFunction {
+
+ public final String path;
+ public final HttpMethod method;
+ private final Supplier supplier;
+ private final Function function;
+ private final Class tClass;
+
+ public AbstractHtmxEndpoint(String path, HttpMethod method, Function function, T... tClass) {
+ this.path = path;
+ this.method = method;
+ this.function = function;
+ this.tClass = getClassOf(tClass);
+ this.supplier = null;
+
+ }
+
+ static Class getClassOf(T[] array) {
+ return (Class) array.getClass().getComponentType();
+ }
+
+ public AbstractHtmxEndpoint(String path, HttpMethod method, Supplier supplier) {
+ this.path = path;
+ this.method = method;
+ this.supplier = supplier;
+ this.function = null;
+ this.tClass = null;
+ }
+
+ @Override
+ @NonNull
+ public Optional> route(@NonNull ServerRequest request) {
+ RequestPredicate predicate = RequestPredicates.method(method).and(RequestPredicates.path(path));
+ if (predicate.test(request)) {
+ R model = getBody(request);
+ if (model instanceof ModelAndView modelAndView) {
+ Map modelData = modelAndView.getModel();
+ return Optional.of(
+ req -> RenderingResponse.create(templateName(model))
+ .modelAttributes(modelData)
+ .build()
+ );
+ } else {
+ return Optional.of(
+ req -> RenderingResponse.create(templateName(model))
+ .modelAttribute(model)
+ .build());
+ }
+ }
+ return Optional.empty();
+ }
+
+ abstract String templateName(R modelAndView);
+
+ private R getBody(ServerRequest req) {
+ if (function == null && supplier != null) {
+ return supplier.get();
+ }
+ try {
+ if (function != null) {
+ MediaType mediaType = req.headers().contentType().orElse(MediaType.APPLICATION_OCTET_STREAM);
+ if(mediaType.includes(MediaType.APPLICATION_FORM_URLENCODED)){
+ T formData = req.bind(tClass);
+ return function.apply(
+ formData
+ );
+ }
+ T body = req.body(tClass);
+ return function.apply(
+ body
+ );
+ }
+ } catch (ServletException | IOException e) {
+ throw new RuntimeException(e);
+ } catch (BindException e) {
+ throw new RuntimeException(e);
+ }
+ throw new RuntimeException("Failed to get body");
+ }
+
+ public String call() {
+ return "hx-" + method.name().toLowerCase() + " =\"" + path + "\"";
+ }
+
+}
diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxEndpoint.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxEndpoint.java
new file mode 100644
index 00000000..6178a7a7
--- /dev/null
+++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxEndpoint.java
@@ -0,0 +1,30 @@
+package io.github.wimdeblauwe.htmx.spring.boot.endpoint;
+
+import org.springframework.http.HttpMethod;
+
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.springframework.web.servlet.ModelAndView;
+
+public class HtmxEndpoint extends AbstractHtmxEndpoint {
+
+ public HtmxEndpoint(String path, HttpMethod method,
+ Supplier supplier) {
+ super(path, method, supplier);
+ }
+
+ @Override
+ String templateName(S responseType) {
+ if(responseType instanceof String viewName){
+ return viewName;
+ } else if (responseType instanceof ModelAndView modelAndView) {
+ return modelAndView.getViewName();
+ }
+ throw new IllegalArgumentException("HtmxEndpoint doesnt support" + responseType.getClass());
+ }
+
+ public HtmxEndpoint(String path, HttpMethod method, Function function, T... tClass) {
+ super(path, method, function, tClass);
+ }
+}
diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxEndpointConfig.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxEndpointConfig.java
new file mode 100644
index 00000000..5d453b01
--- /dev/null
+++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxEndpointConfig.java
@@ -0,0 +1,93 @@
+package io.github.wimdeblauwe.htmx.spring.boot.endpoint;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.stereotype.Controller;
+import org.springframework.util.ReflectionUtils;
+import org.springframework.web.servlet.function.RouterFunction;
+import org.springframework.web.servlet.function.support.RouterFunctionMapping;
+
+@AutoConfiguration
+@EnableConfigurationProperties(HtmxEndpointConfig.HtmxEndpointProperties.class)
+public class HtmxEndpointConfig {
+
+ private final Logger logger = LoggerFactory.getLogger(
+ io.github.wimdeblauwe.htmx.spring.boot.endpoint.HtmxEndpointConfig.class);
+
+ private final ApplicationContext applicationContext;
+ private final RouterFunctionMapping routerFunctionMapping;
+ private final HtmxEndpointProperties htmxEndpointProperties;
+
+ public HtmxEndpointConfig(ApplicationContext applicationContext,
+ RouterFunctionMapping routerFunctionMapping,
+ HtmxEndpointProperties htmxEndpointProperties) {
+ this.applicationContext = applicationContext;
+ this.routerFunctionMapping = routerFunctionMapping;
+ this.htmxEndpointProperties = htmxEndpointProperties;
+ }
+
+ @ConfigurationProperties("htmx.endpoint")
+ public static class HtmxEndpointProperties {
+
+ private final List> annotationClassesToScan;
+
+ public HtmxEndpointProperties(List> annotationClassesToScan) {
+ if (annotationClassesToScan == null || annotationClassesToScan.isEmpty()) {
+ this.annotationClassesToScan = Collections.singletonList(Controller.class);
+ } else {
+ this.annotationClassesToScan = annotationClassesToScan;
+ }
+ }
+
+ public List> annotationClassesToScan() {
+ return annotationClassesToScan;
+ }
+ }
+
+
+ @Bean
+ ApplicationRunner applicationRunner() {
+ return args -> {
+ Map beans = new HashMap<>();
+ for (Class extends Annotation> aClass : htmxEndpointProperties.annotationClassesToScan()) {
+ beans.putAll(applicationContext.getBeansWithAnnotation(aClass));
+ }
+ beans.values().forEach(bean ->
+ {
+ List fieldList = Arrays.stream(bean.getClass().getDeclaredFields())
+ .filter(method -> AbstractHtmxEndpoint.class.isAssignableFrom(method.getType()))
+ .toList();
+
+ fieldList.forEach(field -> {
+ AbstractHtmxEndpoint, ?> function = (AbstractHtmxEndpoint, ?>) ReflectionUtils.getField(field, bean);
+ if (function == null) {
+ throw new RuntimeException("Router function could not be found");
+ }
+ logger.info("Add endpoint: {}, with method: {}", function.path, function.method);
+ if (routerFunctionMapping.getRouterFunction() == null) {
+ routerFunctionMapping.setRouterFunction(function);
+ } else {
+ RouterFunction> routerFunction = routerFunctionMapping.getRouterFunction().andOther(function);
+ routerFunctionMapping.setRouterFunction(routerFunction);
+ }
+ });
+ }
+ );
+ System.out.println(routerFunctionMapping.getRouterFunction());
+ };
+ }
+}
diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxFormConverter.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxFormConverter.java
new file mode 100644
index 00000000..e71696b2
--- /dev/null
+++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/endpoint/HtmxFormConverter.java
@@ -0,0 +1,82 @@
+package io.github.wimdeblauwe.htmx.spring.boot.endpoint;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.http.HttpInputMessage;
+import org.springframework.http.HttpOutputMessage;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+import org.springframework.lang.NonNull;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StreamUtils;
+import org.springframework.util.StringUtils;
+
+import java.io.IOException;
+import java.net.URLDecoder;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+
+@Component
+public class HtmxFormConverter implements HttpMessageConverter