diff --git a/build.gradle b/build.gradle
index b01ff76..f406e39 100644
--- a/build.gradle
+++ b/build.gradle
@@ -22,14 +22,14 @@ repositories {
 
 dependencies {
 	implementation 'com.google.code.gson:gson:2.10.1'
-	implementation 'io.javalin:javalin:5.6.3'
+	implementation 'io.javalin:javalin:6.0.0-beta.3'
 	implementation 'org.slf4j:slf4j-simple:2.0.9'
 	implementation 'commons-io:commons-io:2.15.1'
 	implementation 'org.jetbrains:annotations:24.1.0'
 
 	testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2'
 	testImplementation "org.junit.jupiter:junit-jupiter-params:5.9.2"
-	testImplementation 'io.javalin:javalin-testtools:5.6.3'
+	testImplementation 'io.javalin:javalin-testtools:6.0.0-beta.3'
 
 	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
 }
diff --git a/checkstyle.xml b/checkstyle.xml
index 2690e6f..015bf1b 100644
--- a/checkstyle.xml
+++ b/checkstyle.xml
@@ -156,7 +156,7 @@
 
 		
 		
-		
+
 		
 			
 		
diff --git a/src/main/java/net/fabricmc/meta/FabricMeta.java b/src/main/java/net/fabricmc/meta/FabricMeta.java
index e80714f..8fb8aba 100644
--- a/src/main/java/net/fabricmc/meta/FabricMeta.java
+++ b/src/main/java/net/fabricmc/meta/FabricMeta.java
@@ -35,15 +35,19 @@
 
 import net.fabricmc.meta.data.VersionDatabase;
 import net.fabricmc.meta.utils.Reference;
+import net.fabricmc.meta.web.CacheHandler;
 import net.fabricmc.meta.web.WebServer;
 
 public class FabricMeta {
+	// TODO remove all this static access
+	@Deprecated()
 	public static volatile VersionDatabase database;
 
 	private static final Logger LOGGER = LoggerFactory.getLogger(VersionDatabase.class);
 	private static final Map config = new HashMap<>();
 	private static boolean configInitialized;
 	private static URL heartbeatUrl; // URL pinged with every successful update()
+	private static CacheHandler cacheHandler = new CacheHandler();
 
 	public static void main(String[] args) {
 		Path configFile = Paths.get("config.json");
@@ -77,12 +81,14 @@ public static void main(String[] args) {
 		ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
 		executorService.scheduleAtFixedRate(FabricMeta::update, 1, 1, TimeUnit.MINUTES);
 
-		WebServer.start();
+		WebServer webServer = new WebServer(() -> database, cacheHandler);
+		webServer.createServer().start(5555);
 	}
 
 	private static void update() {
 		try {
 			database = VersionDatabase.generate();
+			cacheHandler.invalidateCache();
 			updateHeartbeat();
 		} catch (Throwable t) {
 			if (database == null) {
diff --git a/src/main/java/net/fabricmc/meta/data/DataProvider.java b/src/main/java/net/fabricmc/meta/data/DataProvider.java
new file mode 100644
index 0000000..ee73424
--- /dev/null
+++ b/src/main/java/net/fabricmc/meta/data/DataProvider.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2023 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.meta.data;
+
+import java.util.List;
+
+import com.google.gson.JsonObject;
+
+import net.fabricmc.meta.models.BaseVersion;
+import net.fabricmc.meta.models.MavenBuildGameVersion;
+import net.fabricmc.meta.models.MavenBuildVersion;
+import net.fabricmc.meta.models.MavenVersion;
+import net.fabricmc.meta.utils.LoaderMeta;
+
+public interface DataProvider {
+	@Deprecated // TODO work to remove
+	VersionDatabase getVersionDatabase();
+
+	default List getGameVersions() {
+		return getVersionDatabase().game;
+	}
+
+	default List getMappingVersions() {
+		return getVersionDatabase().mappings;
+	}
+
+	default List getIntermediaryVersions() {
+		return getVersionDatabase().intermediary;
+	}
+
+	default List getLoaderVersions() {
+		return getVersionDatabase().getLoader();
+	}
+
+	default JsonObject getLoaderInstallerJson(String mavenNotation) {
+		return LoaderMeta.getMeta(mavenNotation);
+	}
+}
diff --git a/src/main/java/net/fabricmc/meta/data/VersionDatabase.java b/src/main/java/net/fabricmc/meta/data/VersionDatabase.java
index 8982b3f..54bd90e 100644
--- a/src/main/java/net/fabricmc/meta/data/VersionDatabase.java
+++ b/src/main/java/net/fabricmc/meta/data/VersionDatabase.java
@@ -30,13 +30,13 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import net.fabricmc.meta.models.BaseVersion;
+import net.fabricmc.meta.models.MavenBuildGameVersion;
+import net.fabricmc.meta.models.MavenBuildVersion;
+import net.fabricmc.meta.models.MavenUrlVersion;
+import net.fabricmc.meta.models.MavenVersion;
 import net.fabricmc.meta.utils.MinecraftLauncherMeta;
 import net.fabricmc.meta.utils.PomParser;
-import net.fabricmc.meta.web.models.BaseVersion;
-import net.fabricmc.meta.web.models.MavenBuildGameVersion;
-import net.fabricmc.meta.web.models.MavenBuildVersion;
-import net.fabricmc.meta.web.models.MavenUrlVersion;
-import net.fabricmc.meta.web.models.MavenVersion;
 
 public class VersionDatabase {
 	public static final PomParser MAPPINGS_PARSER = new PomParser(LOCAL_FABRIC_MAVEN_URL + "net/fabricmc/yarn/maven-metadata.xml");
@@ -63,10 +63,8 @@ public static VersionDatabase generate() throws IOException, XMLStreamException
 		database.intermediary = INTERMEDIARY_PARSER.getMeta(MavenVersion::new, "net.fabricmc:intermediary:");
 		database.loader = LOADER_PARSER.getMeta(MavenBuildVersion::new, "net.fabricmc:fabric-loader:", list -> {
 			for (BaseVersion version : list) {
-				if (isPublicLoaderVersion(version)) {
-					version.setStable(true);
-					break;
-				}
+				version.setStable(true);
+				break;
 			}
 		});
 		database.installer = INSTALLER_PARSER.getMeta(MavenUrlVersion::new, "net.fabricmc:fabric-installer:");
@@ -114,14 +112,6 @@ private void loadMcData() throws IOException {
 	}
 
 	public List getLoader() {
-		return loader.stream().filter(VersionDatabase::isPublicLoaderVersion).collect(Collectors.toList());
-	}
-
-	private static boolean isPublicLoaderVersion(BaseVersion version) {
-		return true;
-	}
-
-	public List getAllLoader() {
 		return Collections.unmodifiableList(loader);
 	}
 }
diff --git a/src/main/java/net/fabricmc/meta/web/models/BaseVersion.java b/src/main/java/net/fabricmc/meta/models/BaseVersion.java
similarity index 96%
rename from src/main/java/net/fabricmc/meta/web/models/BaseVersion.java
rename to src/main/java/net/fabricmc/meta/models/BaseVersion.java
index fd2dd39..e6f7a6b 100644
--- a/src/main/java/net/fabricmc/meta/web/models/BaseVersion.java
+++ b/src/main/java/net/fabricmc/meta/models/BaseVersion.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.meta.web.models;
+package net.fabricmc.meta.models;
 
 import java.util.function.Predicate;
 
diff --git a/src/main/java/net/fabricmc/meta/web/models/LoaderInfoBase.java b/src/main/java/net/fabricmc/meta/models/LoaderInfoBase.java
similarity index 94%
rename from src/main/java/net/fabricmc/meta/web/models/LoaderInfoBase.java
rename to src/main/java/net/fabricmc/meta/models/LoaderInfoBase.java
index 41f6a23..6323028 100644
--- a/src/main/java/net/fabricmc/meta/web/models/LoaderInfoBase.java
+++ b/src/main/java/net/fabricmc/meta/models/LoaderInfoBase.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.meta.web.models;
+package net.fabricmc.meta.models;
 
 public interface LoaderInfoBase {
 	MavenBuildVersion getLoader();
diff --git a/src/main/java/net/fabricmc/meta/web/models/LoaderInfoV1.java b/src/main/java/net/fabricmc/meta/models/LoaderInfoV1.java
similarity index 92%
rename from src/main/java/net/fabricmc/meta/web/models/LoaderInfoV1.java
rename to src/main/java/net/fabricmc/meta/models/LoaderInfoV1.java
index 5523c7a..90a077e 100644
--- a/src/main/java/net/fabricmc/meta/web/models/LoaderInfoV1.java
+++ b/src/main/java/net/fabricmc/meta/models/LoaderInfoV1.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.meta.web.models;
+package net.fabricmc.meta.models;
 
 import com.google.gson.JsonObject;
 import org.jetbrains.annotations.Nullable;
@@ -34,7 +34,7 @@ public LoaderInfoV1(MavenBuildVersion loader, MavenBuildGameVersion mappings) {
 	}
 
 	public LoaderInfoV1 populateMeta() {
-		launcherMeta = LoaderMeta.getMeta(this);
+		launcherMeta = LoaderMeta.getMeta(getLoader().getMaven());
 		return this;
 	}
 
diff --git a/src/main/java/net/fabricmc/meta/web/models/LoaderInfoV2.java b/src/main/java/net/fabricmc/meta/models/LoaderInfoV2.java
similarity index 93%
rename from src/main/java/net/fabricmc/meta/web/models/LoaderInfoV2.java
rename to src/main/java/net/fabricmc/meta/models/LoaderInfoV2.java
index 0547f43..d8d569a 100644
--- a/src/main/java/net/fabricmc/meta/web/models/LoaderInfoV2.java
+++ b/src/main/java/net/fabricmc/meta/models/LoaderInfoV2.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.meta.web.models;
+package net.fabricmc.meta.models;
 
 import com.google.gson.JsonObject;
 import org.jetbrains.annotations.Nullable;
@@ -34,7 +34,7 @@ public LoaderInfoV2(MavenBuildVersion loader, MavenVersion intermediary) {
 	}
 
 	public LoaderInfoV2 populateMeta() {
-		launcherMeta = LoaderMeta.getMeta(this);
+		launcherMeta = LoaderMeta.getMeta(getLoader().getMaven());
 		return this;
 	}
 
diff --git a/src/main/java/net/fabricmc/meta/web/models/MavenBuildGameVersion.java b/src/main/java/net/fabricmc/meta/models/MavenBuildGameVersion.java
similarity index 96%
rename from src/main/java/net/fabricmc/meta/web/models/MavenBuildGameVersion.java
rename to src/main/java/net/fabricmc/meta/models/MavenBuildGameVersion.java
index dd13358..14d8f0d 100644
--- a/src/main/java/net/fabricmc/meta/web/models/MavenBuildGameVersion.java
+++ b/src/main/java/net/fabricmc/meta/models/MavenBuildGameVersion.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.meta.web.models;
+package net.fabricmc.meta.models;
 
 import net.fabricmc.meta.utils.YarnVersionParser;
 
diff --git a/src/main/java/net/fabricmc/meta/web/models/MavenBuildVersion.java b/src/main/java/net/fabricmc/meta/models/MavenBuildVersion.java
similarity index 96%
rename from src/main/java/net/fabricmc/meta/web/models/MavenBuildVersion.java
rename to src/main/java/net/fabricmc/meta/models/MavenBuildVersion.java
index 104a299..bc282b8 100644
--- a/src/main/java/net/fabricmc/meta/web/models/MavenBuildVersion.java
+++ b/src/main/java/net/fabricmc/meta/models/MavenBuildVersion.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.meta.web.models;
+package net.fabricmc.meta.models;
 
 public class MavenBuildVersion extends MavenVersion {
 	String separator;
diff --git a/src/main/java/net/fabricmc/meta/web/models/MavenUrlVersion.java b/src/main/java/net/fabricmc/meta/models/MavenUrlVersion.java
similarity index 96%
rename from src/main/java/net/fabricmc/meta/web/models/MavenUrlVersion.java
rename to src/main/java/net/fabricmc/meta/models/MavenUrlVersion.java
index 010f322..69b0654 100644
--- a/src/main/java/net/fabricmc/meta/web/models/MavenUrlVersion.java
+++ b/src/main/java/net/fabricmc/meta/models/MavenUrlVersion.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.meta.web.models;
+package net.fabricmc.meta.models;
 
 import net.fabricmc.meta.utils.Reference;
 
diff --git a/src/main/java/net/fabricmc/meta/web/models/MavenVersion.java b/src/main/java/net/fabricmc/meta/models/MavenVersion.java
similarity index 95%
rename from src/main/java/net/fabricmc/meta/web/models/MavenVersion.java
rename to src/main/java/net/fabricmc/meta/models/MavenVersion.java
index 162ea30..4cb099e 100644
--- a/src/main/java/net/fabricmc/meta/web/models/MavenVersion.java
+++ b/src/main/java/net/fabricmc/meta/models/MavenVersion.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.meta.web.models;
+package net.fabricmc.meta.models;
 
 public class MavenVersion extends BaseVersion {
 	String maven;
diff --git a/src/main/java/net/fabricmc/meta/utils/LoaderMeta.java b/src/main/java/net/fabricmc/meta/utils/LoaderMeta.java
index 2bb910d..e62a943 100644
--- a/src/main/java/net/fabricmc/meta/utils/LoaderMeta.java
+++ b/src/main/java/net/fabricmc/meta/utils/LoaderMeta.java
@@ -22,17 +22,18 @@
 import java.io.IOException;
 import java.net.URL;
 
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
 import com.google.gson.JsonObject;
 import org.apache.commons.io.FileUtils;
-
-import net.fabricmc.meta.web.WebServer;
-import net.fabricmc.meta.web.models.LoaderInfoBase;
+import org.jetbrains.annotations.Nullable;
 
 public class LoaderMeta {
-	public static final File BASE_DIR = new File("metadata");
+	private static final File BASE_DIR = new File("metadata");
+	private static final Gson GSON = new GsonBuilder().create();
 
-	public static JsonObject getMeta(LoaderInfoBase loaderInfo) {
-		String loaderMaven = loaderInfo.getLoader().getMaven();
+	@Nullable
+	public static JsonObject getMeta(String loaderMaven) {
 		String[] split = loaderMaven.split(":");
 		String path = String.format("%s/%s/%s", split[0].replaceAll("\\.", "/"), split[1], split[2]);
 		String filename = String.format("%s-%s.json", split[1], split[2]);
@@ -51,7 +52,7 @@ public static JsonObject getMeta(LoaderInfoBase loaderInfo) {
 		}
 
 		try {
-			JsonObject jsonObject = WebServer.GSON.fromJson(new FileReader(launcherMetaFile), JsonObject.class);
+			JsonObject jsonObject = GSON.fromJson(new FileReader(launcherMetaFile), JsonObject.class);
 			return jsonObject;
 		} catch (FileNotFoundException e) {
 			e.printStackTrace();
diff --git a/src/main/java/net/fabricmc/meta/utils/PomParser.java b/src/main/java/net/fabricmc/meta/utils/PomParser.java
index 862ff27..ae7dc59 100644
--- a/src/main/java/net/fabricmc/meta/utils/PomParser.java
+++ b/src/main/java/net/fabricmc/meta/utils/PomParser.java
@@ -33,7 +33,7 @@
 import javax.xml.stream.XMLStreamException;
 import javax.xml.stream.XMLStreamReader;
 
-import net.fabricmc.meta.web.models.BaseVersion;
+import net.fabricmc.meta.models.BaseVersion;
 
 public class PomParser {
 	public String path;
diff --git a/src/main/java/net/fabricmc/meta/web/CacheHandler.java b/src/main/java/net/fabricmc/meta/web/CacheHandler.java
new file mode 100644
index 0000000..29510cb
--- /dev/null
+++ b/src/main/java/net/fabricmc/meta/web/CacheHandler.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2023 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.meta.web;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import io.javalin.http.Handler;
+
+public class CacheHandler {
+	private final Map cache = new ConcurrentHashMap<>();
+
+	public CacheHandler() {
+	}
+
+	public Handler before() {
+		return ctx -> {
+			Response response = cache.get(ctx.path());
+
+			if (response == null) {
+				return;
+			}
+
+			// Replay the response
+			ctx.status(response.status());
+			response.headers().forEach(ctx::header);
+			ctx.contentType(response.contentType());
+			ctx.result(response.body());
+
+			ctx.skipRemainingHandlers();
+		};
+	}
+
+	public Handler after() {
+		return ctx -> {
+			if (ctx.statusCode() != 200) {
+				return;
+			}
+
+			if (!ctx.queryParamMap().isEmpty()) {
+				// Don't cache any requests with query params to prevent the cache from growing too big.
+				// Maybe look into something better here
+				return;
+			}
+
+			cache.put(ctx.path(), new Response(
+					ctx.statusCode(),
+					readAllBytes(ctx.resultInputStream()),
+					ctx.headerMap(),
+					ctx.res().getContentType()));
+		};
+	}
+
+	private static byte[] readAllBytes(InputStream is) throws IOException {
+		is.reset();
+		byte[] bytes = is.readAllBytes();
+		is.reset();
+		return bytes;
+	}
+
+	public void invalidateCache() {
+		cache.clear();
+	}
+
+	private record Response(
+			int status,
+			byte[] body,
+			Map headers,
+			String contentType) {
+	}
+}
diff --git a/src/main/java/net/fabricmc/meta/web/Endpoint.java b/src/main/java/net/fabricmc/meta/web/Endpoint.java
new file mode 100644
index 0000000..5f7951b
--- /dev/null
+++ b/src/main/java/net/fabricmc/meta/web/Endpoint.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2023 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.meta.web;
+
+import java.time.Duration;
+import java.util.List;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import io.javalin.apibuilder.EndpointGroup;
+import io.javalin.http.ContentType;
+import io.javalin.http.Context;
+import io.javalin.http.Handler;
+
+import net.fabricmc.meta.data.DataProvider;
+
+public abstract class Endpoint {
+	private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+
+	protected final DataProvider dataProvider;
+
+	protected Endpoint(DataProvider dataProvider) {
+		this.dataProvider = dataProvider;
+	}
+
+	public abstract EndpointGroup routes();
+
+	protected Handler cache(Duration duration) {
+		return ctx -> {
+			ctx.header("Cache-Control", "public, max-age=" + duration.getSeconds());
+		};
+	}
+
+	// Return a json list with no params
+	protected Handler result(JsonListHandler handler) {
+		return ctx -> {
+			List extends JsonModel> result = handler.apply(dataProvider);
+			jsonResult(ctx, result);
+		};
+	}
+
+	// Return a json list with one string param
+	protected Handler result(String key, JsonListHandler1 handler) {
+		return ctx -> {
+			final String value = ctx.pathParamAsClass(key, String.class).get();
+			List extends JsonModel> result = handler.apply(dataProvider, value);
+			jsonResult(ctx, result);
+		};
+	}
+
+	// Return a json list with two string params
+	protected Handler result(String key1, String key2, JsonListHandler2 handler) {
+		return ctx -> {
+			final String value1 = ctx.pathParamAsClass(key1, String.class).get();
+			final String value2 = ctx.pathParamAsClass(key2, String.class).get();
+			List extends JsonModel> result = handler.apply(dataProvider, value1, value2);
+			jsonResult(ctx, result);
+		};
+	}
+
+	// Return a json list with two string params
+	protected Handler result(String key1, String key2, JsonHandler2 handler) {
+		return ctx -> {
+			final String value1 = ctx.pathParamAsClass(key1, String.class).get();
+			final String value2 = ctx.pathParamAsClass(key2, String.class).get();
+			JsonModel result = handler.apply(dataProvider, value1, value2);
+			jsonResult(ctx, result);
+		};
+	}
+
+	protected void jsonResult(Context ctx, Object result) {
+		ctx.contentType(ContentType.APPLICATION_JSON);
+		ctx.result(GSON.toJson(result));
+	}
+
+	protected interface JsonListHandler {
+		List extends JsonModel> apply(DataProvider dataProvider);
+	}
+
+	protected interface JsonListHandler1 {
+		List extends JsonModel> apply(DataProvider dataProvider, String key1);
+	}
+
+	protected interface JsonListHandler2 {
+		List extends JsonModel> apply(DataProvider dataProvider, String key1, String key2);
+	}
+
+	protected interface JsonHandler2 {
+		JsonModel apply(DataProvider dataProvider, String key1, String key2);
+	}
+}
diff --git a/src/main/java/net/fabricmc/meta/web/EndpointsV1.java b/src/main/java/net/fabricmc/meta/web/EndpointsV1.java
deleted file mode 100644
index 8bb14e3..0000000
--- a/src/main/java/net/fabricmc/meta/web/EndpointsV1.java
+++ /dev/null
@@ -1,112 +0,0 @@
-/*
- * Copyright (c) 2019 FabricMC
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package net.fabricmc.meta.web;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.function.Predicate;
-import java.util.stream.Collectors;
-
-import io.javalin.http.Context;
-
-import net.fabricmc.meta.FabricMeta;
-import net.fabricmc.meta.web.models.LoaderInfoV1;
-import net.fabricmc.meta.web.models.MavenBuildGameVersion;
-import net.fabricmc.meta.web.models.MavenBuildVersion;
-
-@SuppressWarnings("Duplicates")
-public class EndpointsV1 {
-	public static void setup() {
-		WebServer.jsonGet("/v1/versions", () -> FabricMeta.database);
-
-		WebServer.jsonGet("/v1/versions/game", () -> FabricMeta.database.game);
-		WebServer.jsonGet("/v1/versions/game/{game_version}", context -> filter(context, FabricMeta.database.game));
-
-		WebServer.jsonGet("/v1/versions/mappings", () -> FabricMeta.database.mappings);
-		WebServer.jsonGet("/v1/versions/mappings/{game_version}", context -> filter(context, FabricMeta.database.mappings));
-
-		WebServer.jsonGet("/v1/versions/loader", () -> FabricMeta.database.getLoader());
-		WebServer.jsonGet("/v1/versions/loader/{game_version}", EndpointsV1::getLoaderInfoAll);
-		WebServer.jsonGet("/v1/versions/loader/{game_version}/{loader_version}", EndpointsV1::getLoaderInfo);
-	}
-
-	private static > List filter(Context context, List versionList) {
-		if (!context.pathParamMap().containsKey("game_version")) {
-			return Collections.emptyList();
-		}
-
-		return versionList.stream().filter(t -> t.test(context.pathParam("game_version"))).collect(Collectors.toList());
-	}
-
-	private static Object getLoaderInfo(Context context) {
-		if (!context.pathParamMap().containsKey("game_version")) {
-			return null;
-		}
-
-		if (!context.pathParamMap().containsKey("loader_version")) {
-			return null;
-		}
-
-		String gameVersion = context.pathParam("game_version");
-		String loaderVersion = context.pathParam("loader_version");
-
-		MavenBuildVersion loader = FabricMeta.database.getAllLoader().stream()
-				.filter(mavenBuildVersion -> loaderVersion.equals(mavenBuildVersion.getVersion()))
-				.findFirst().orElse(null);
-
-		MavenBuildGameVersion mappings = FabricMeta.database.mappings.stream()
-				.filter(t -> t.test(gameVersion))
-				.findFirst().orElse(null);
-
-		if (loader == null) {
-			context.status(400);
-			return "no loader version found for " + gameVersion;
-		}
-
-		if (mappings == null) {
-			context.status(400);
-			return "no mappings version found for " + gameVersion;
-		}
-
-		return new LoaderInfoV1(loader, mappings).populateMeta();
-	}
-
-	private static Object getLoaderInfoAll(Context context) {
-		if (!context.pathParamMap().containsKey("game_version")) {
-			return null;
-		}
-
-		String gameVersion = context.pathParam("game_version");
-
-		MavenBuildGameVersion mappings = FabricMeta.database.mappings.stream()
-				.filter(t -> t.test(gameVersion))
-				.findFirst().orElse(null);
-
-		if (mappings == null) {
-			return Collections.emptyList();
-		}
-
-		List infoList = new ArrayList<>();
-
-		for (MavenBuildVersion loader : FabricMeta.database.getLoader()) {
-			infoList.add(new LoaderInfoV1(loader, mappings));
-		}
-
-		return infoList;
-	}
-}
diff --git a/src/main/java/net/fabricmc/meta/web/JsonModel.java b/src/main/java/net/fabricmc/meta/web/JsonModel.java
new file mode 100644
index 0000000..2bdcf0d
--- /dev/null
+++ b/src/main/java/net/fabricmc/meta/web/JsonModel.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2023 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.meta.web;
+
+// A marker interface for all records that are serialized to JSON as part of the public api.
+public interface JsonModel {
+}
diff --git a/src/main/java/net/fabricmc/meta/web/ProfileHandler.java b/src/main/java/net/fabricmc/meta/web/ProfileHandler.java
index 8cc3ced..0935fec 100644
--- a/src/main/java/net/fabricmc/meta/web/ProfileHandler.java
+++ b/src/main/java/net/fabricmc/meta/web/ProfileHandler.java
@@ -35,8 +35,9 @@
 import com.google.gson.JsonObject;
 import org.apache.commons.io.IOUtils;
 
+import net.fabricmc.meta.models.LoaderInfoV2;
 import net.fabricmc.meta.utils.Reference;
-import net.fabricmc.meta.web.models.LoaderInfoV2;
+import net.fabricmc.meta.web.v2.EndpointsV2;
 
 public class ProfileHandler {
 	private static final Executor EXECUTOR = Executors.newFixedThreadPool(2);
diff --git a/src/main/java/net/fabricmc/meta/web/ServerBootstrap.java b/src/main/java/net/fabricmc/meta/web/ServerBootstrap.java
index 2f58149..1292095 100644
--- a/src/main/java/net/fabricmc/meta/web/ServerBootstrap.java
+++ b/src/main/java/net/fabricmc/meta/web/ServerBootstrap.java
@@ -41,8 +41,8 @@
 import org.apache.commons.io.FileUtils;
 
 import net.fabricmc.meta.FabricMeta;
+import net.fabricmc.meta.models.BaseVersion;
 import net.fabricmc.meta.utils.Reference;
-import net.fabricmc.meta.web.models.BaseVersion;
 
 public class ServerBootstrap {
 	private static final Path CACHE_DIR = Paths.get("metadata", "installer");
@@ -62,7 +62,7 @@ private static Handler boostrapHandler() {
 
 			final String installerVersion = getAndValidateVersion(ctx, FabricMeta.database.installer, "installer_version");
 			final String gameVersion = getAndValidateVersion(ctx, FabricMeta.database.game, "game_version");
-			final String loaderVersion = getAndValidateVersion(ctx, FabricMeta.database.getAllLoader(), "loader_version");
+			final String loaderVersion = getAndValidateVersion(ctx, FabricMeta.database.getLoader(), "loader_version");
 
 			validateLoaderVersion(loaderVersion);
 			validateInstallerVersion(installerVersion);
diff --git a/src/main/java/net/fabricmc/meta/web/WebServer.java b/src/main/java/net/fabricmc/meta/web/WebServer.java
index aa514b5..d2a535d 100644
--- a/src/main/java/net/fabricmc/meta/web/WebServer.java
+++ b/src/main/java/net/fabricmc/meta/web/WebServer.java
@@ -16,6 +16,10 @@
 
 package net.fabricmc.meta.web;
 
+import static io.javalin.apibuilder.ApiBuilder.after;
+import static io.javalin.apibuilder.ApiBuilder.before;
+import static io.javalin.apibuilder.ApiBuilder.path;
+
 import java.util.function.Function;
 import java.util.function.Supplier;
 
@@ -24,34 +28,53 @@
 import io.javalin.Javalin;
 import io.javalin.http.Context;
 import io.javalin.http.Header;
+import io.javalin.plugin.bundled.CorsPlugin;
 import io.javalin.plugin.bundled.CorsPluginConfig;
+import io.javalin.plugin.bundled.RouteOverviewPlugin;
+
+import net.fabricmc.meta.data.DataProvider;
+import net.fabricmc.meta.web.v1.EndpointsV1;
+import net.fabricmc.meta.web.v2.EndpointsV2;
 
 public class WebServer {
+	@Deprecated(forRemoval = true)
 	public static Javalin javalin;
-	public static Gson GSON = new GsonBuilder().setPrettyPrinting().create();
 
-	public static Javalin create() {
-		if (javalin != null) {
-			javalin.stop();
-		}
+	private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+
+	private final DataProvider dataProvider;
+	private final CacheHandler cacheHandler;
+
+	private final EndpointsV1 endpointsV1;
+
+	public WebServer(DataProvider dataProvider, CacheHandler cacheHandler) {
+		this.dataProvider = dataProvider;
+		this.cacheHandler = cacheHandler;
 
-		javalin = Javalin.create(config -> {
-			config.plugins.enableRouteOverview("/");
+		endpointsV1 = new EndpointsV1(dataProvider);
+	}
+
+	public Javalin createServer() {
+		Javalin javalin = Javalin.create(config -> {
+			config.useVirtualThreads = true;
 			config.showJavalinBanner = false;
-			config.plugins.enableCors(cors -> cors.add(CorsPluginConfig::anyHost));
+			config.registerPlugin(new RouteOverviewPlugin(routeOverview -> routeOverview.path = "/"));
+			config.registerPlugin(new CorsPlugin(cors -> cors.addRule(CorsPluginConfig.CorsRule::anyHost)));
+
+			config.router.apiBuilder(() -> {
+				before(cacheHandler.before());
+				after(cacheHandler.after());
+				path("v1", endpointsV1.routes());
+			});
 		});
 
-		EndpointsV1.setup();
+		// TODO remove this
+		WebServer.javalin = javalin;
 		EndpointsV2.setup();
 
 		return javalin;
 	}
 
-	public static void start() {
-		assert javalin == null;
-		create().start(5555);
-	}
-
 	public static  void jsonGet(String route, Supplier supplier) {
 		javalin.get(route, ctx -> {
 			T object = supplier.get();
diff --git a/src/main/java/net/fabricmc/meta/web/v1/EndpointsV1.java b/src/main/java/net/fabricmc/meta/web/v1/EndpointsV1.java
new file mode 100644
index 0000000..83bbe45
--- /dev/null
+++ b/src/main/java/net/fabricmc/meta/web/v1/EndpointsV1.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.meta.web.v1;
+
+import static io.javalin.apibuilder.ApiBuilder.before;
+import static io.javalin.apibuilder.ApiBuilder.get;
+
+import java.time.Duration;
+
+import io.javalin.apibuilder.EndpointGroup;
+
+import net.fabricmc.meta.data.DataProvider;
+import net.fabricmc.meta.web.Endpoint;
+
+public class EndpointsV1 extends Endpoint {
+	public EndpointsV1(DataProvider dataProvider) {
+		super(dataProvider);
+	}
+
+	@Override
+	public EndpointGroup routes() {
+		return () -> {
+			before(cache(Duration.ofMinutes(1)));
+
+			get("versions", ctx -> jsonResult(ctx, dataProvider.getVersionDatabase()));
+			get("versions/game", result(ModelsV1::gameVersions));
+			get("versions/game/{game_version}", result("game_version", ModelsV1::gameVersions));
+			get("versions/mappings", result(ModelsV1::mappingVersions));
+			get("versions/mappings/{game_version}", result("game_version", ModelsV1::mappingVersions));
+			get("versions/loader", result(ModelsV1::loaderVersions));
+			get("versions/loader/{game_version}", result("game_version", ModelsV1::loaderInfo));
+			get("versions/loader/{game_version}/{loader_version}", result("game_version", "loader_version", ModelsV1::loaderLauncherInfo));
+		};
+	}
+}
diff --git a/src/main/java/net/fabricmc/meta/web/v1/ModelsV1.java b/src/main/java/net/fabricmc/meta/web/v1/ModelsV1.java
new file mode 100644
index 0000000..046a236
--- /dev/null
+++ b/src/main/java/net/fabricmc/meta/web/v1/ModelsV1.java
@@ -0,0 +1,193 @@
+/*
+ * Copyright (c) 2023 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.meta.web.v1;
+
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+
+import com.google.gson.JsonObject;
+import io.javalin.http.BadRequestResponse;
+import io.javalin.http.InternalServerErrorResponse;
+
+import net.fabricmc.meta.data.DataProvider;
+import net.fabricmc.meta.models.BaseVersion;
+import net.fabricmc.meta.models.MavenBuildGameVersion;
+import net.fabricmc.meta.models.MavenBuildVersion;
+import net.fabricmc.meta.web.JsonModel;
+
+/**
+ * Strongly defined records of the public API. Take extra care when changing method return types and record components.
+ */
+public sealed interface ModelsV1 extends JsonModel permits ModelsV1.GameVersion, ModelsV1.MappingVersion, ModelsV1.LoaderVersion, ModelsV1.LoaderInfo, ModelsV1.LoaderLauncherInfo {
+	/**
+	 * /v1/versions/game
+	 */
+	static List gameVersions(DataProvider dataProvider) {
+		LinkedList versions = new LinkedList<>();
+
+		for (BaseVersion version : dataProvider.getGameVersions()) {
+			versions.add(GameVersion.from(version));
+		}
+
+		return versions;
+	}
+
+	/**
+	 * /v1/game/{game_version}
+	 */
+	static List gameVersions(DataProvider dataProvider, String gameVersion) {
+		for (BaseVersion version : dataProvider.getGameVersions()) {
+			if (version.getVersion().equals(gameVersion)) {
+				return List.of(GameVersion.from(version));
+			}
+		}
+
+		return Collections.emptyList();
+	}
+
+	/**
+	 * /v1/versions/mappings
+	 */
+	static List mappingVersions(DataProvider dataProvider) {
+		LinkedList versions = new LinkedList<>();
+
+		for (MavenBuildGameVersion version : dataProvider.getMappingVersions()) {
+			versions.add(MappingVersion.from(version));
+		}
+
+		return versions;
+	}
+
+	/**
+	 * /v1/mappings/{game_version}
+	 */
+	static List mappingVersions(DataProvider dataProvider, String gameVersion) {
+		LinkedList versions = new LinkedList<>();
+
+		for (MavenBuildGameVersion version : dataProvider.getMappingVersions()) {
+			if (version.getGameVersion().equals(gameVersion)) {
+				versions.add(MappingVersion.from(version));
+			}
+		}
+
+		return versions;
+	}
+
+	/**
+	 * /v1/versions/loader
+	 */
+	static List loaderVersions(DataProvider dataProvider) {
+		LinkedList versions = new LinkedList<>();
+
+		for (MavenBuildVersion version : dataProvider.getLoaderVersions()) {
+			versions.add(LoaderVersion.from(version));
+		}
+
+		return versions;
+	}
+
+	/**
+	 * /v1/versions/loader/{game_version}/{loader_version}
+	 */
+	static List loaderInfo(DataProvider dataProvider, String gameVersion) {
+		MavenBuildGameVersion mappings = null;
+
+		for (MavenBuildGameVersion version : dataProvider.getMappingVersions()) {
+			if (version.test(gameVersion)) {
+				mappings = version;
+				break;
+			}
+		}
+
+		if (mappings == null) {
+			return Collections.emptyList();
+		}
+
+		List infoList = new LinkedList<>();
+
+		for (MavenBuildVersion loader : dataProvider.getLoaderVersions()) {
+			infoList.add(new LoaderInfo(LoaderVersion.from(loader), MappingVersion.from(mappings)));
+		}
+
+		return infoList;
+	}
+
+	/**
+	 * /v1/versions/loader/{game_version}/{loader_version}
+	 */
+	static LoaderLauncherInfo loaderLauncherInfo(DataProvider dataProvider, String gameVersion, String loaderVersion) {
+		MavenBuildVersion loader = null;
+		MavenBuildGameVersion mappings = null;
+
+		for (MavenBuildVersion version : dataProvider.getLoaderVersions()) {
+			if (loaderVersion.equals(version.getVersion())) {
+				loader = version;
+				break;
+			}
+		}
+
+		for (MavenBuildGameVersion version : dataProvider.getMappingVersions()) {
+			if (version.test(gameVersion)) {
+				mappings = version;
+				break;
+			}
+		}
+
+		if (loader == null) {
+			throw new BadRequestResponse("no loader version found for " + gameVersion);
+		}
+
+		if (mappings == null) {
+			throw new BadRequestResponse("no mappings version found for " + gameVersion);
+		}
+
+		final JsonObject installerJson = dataProvider.getLoaderInstallerJson(loader.getMaven());
+
+		if (installerJson == null) {
+			throw new InternalServerErrorResponse("Failed to load installer json, report to Fabric");
+		}
+
+		return new LoaderLauncherInfo(
+				LoaderVersion.from(loader),
+				MappingVersion.from(mappings),
+				installerJson
+		);
+	}
+
+	record GameVersion(String version, boolean stable) implements ModelsV1 {
+		private static GameVersion from(BaseVersion version) {
+			return new GameVersion(version.getVersion(), version.isStable());
+		}
+	}
+
+	record MappingVersion(String gameVersion, String separator, int build, String maven, String version, boolean stable) implements ModelsV1 {
+		private static MappingVersion from(MavenBuildGameVersion version) {
+			return new MappingVersion(version.getGameVersion(), version.getSeparator(), version.getBuild(), version.getMaven(), version.getVersion(), version.isStable());
+		}
+	}
+
+	record LoaderVersion(String separator, int build, String maven, String version, boolean stable) implements ModelsV1 {
+		private static LoaderVersion from(MavenBuildVersion version) {
+			return new LoaderVersion(version.getSeparator(), version.getBuild(), version.getMaven(), version.getVersion(), version.isStable());
+		}
+	}
+
+	record LoaderInfo(LoaderVersion loader, MappingVersion mappings) implements ModelsV1 { }
+
+	record LoaderLauncherInfo(LoaderVersion loader, MappingVersion mappings, Object launcherMeta) implements ModelsV1 { }
+}
diff --git a/src/main/java/net/fabricmc/meta/web/EndpointsV2.java b/src/main/java/net/fabricmc/meta/web/v2/EndpointsV2.java
similarity index 93%
rename from src/main/java/net/fabricmc/meta/web/EndpointsV2.java
rename to src/main/java/net/fabricmc/meta/web/v2/EndpointsV2.java
index cb488c3..5a55311 100644
--- a/src/main/java/net/fabricmc/meta/web/EndpointsV2.java
+++ b/src/main/java/net/fabricmc/meta/web/v2/EndpointsV2.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package net.fabricmc.meta.web;
+package net.fabricmc.meta.web.v2;
 
 import java.io.InputStream;
 import java.util.ArrayList;
@@ -30,11 +30,14 @@
 import io.javalin.http.Header;
 
 import net.fabricmc.meta.FabricMeta;
-import net.fabricmc.meta.web.models.BaseVersion;
-import net.fabricmc.meta.web.models.LoaderInfoV2;
-import net.fabricmc.meta.web.models.MavenBuildGameVersion;
-import net.fabricmc.meta.web.models.MavenBuildVersion;
-import net.fabricmc.meta.web.models.MavenVersion;
+import net.fabricmc.meta.models.BaseVersion;
+import net.fabricmc.meta.models.LoaderInfoV2;
+import net.fabricmc.meta.models.MavenBuildGameVersion;
+import net.fabricmc.meta.models.MavenBuildVersion;
+import net.fabricmc.meta.models.MavenVersion;
+import net.fabricmc.meta.web.ProfileHandler;
+import net.fabricmc.meta.web.ServerBootstrap;
+import net.fabricmc.meta.web.WebServer;
 
 @SuppressWarnings("Duplicates")
 public class EndpointsV2 {
@@ -98,7 +101,7 @@ private static Object getLoaderInfo(Context context) {
 		String gameVersion = context.pathParam("game_version");
 		String loaderVersion = context.pathParam("loader_version");
 
-		MavenBuildVersion loader = FabricMeta.database.getAllLoader().stream()
+		MavenBuildVersion loader = FabricMeta.database.getLoader().stream()
 				.filter(mavenBuildVersion -> loaderVersion.equals(mavenBuildVersion.getVersion()))
 				.findFirst().orElse(null);
 
diff --git a/src/test/java/net/fabricmc/meta/test/TestUtils.java b/src/test/java/net/fabricmc/meta/test/TestUtils.java
new file mode 100644
index 0000000..573a113
--- /dev/null
+++ b/src/test/java/net/fabricmc/meta/test/TestUtils.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2023 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.meta.test;
+
+import io.javalin.Javalin;
+
+import net.fabricmc.meta.FabricMeta;
+import net.fabricmc.meta.web.CacheHandler;
+import net.fabricmc.meta.web.WebServer;
+
+public class TestUtils {
+	public static Javalin createServer() {
+		CacheHandler cacheHandler = new CacheHandler();
+		return new WebServer(() -> FabricMeta.database, cacheHandler).createServer();
+	}
+}
diff --git a/src/test/java/net/fabricmc/meta/test/integration/ComparisonTests.java b/src/test/java/net/fabricmc/meta/test/integration/ComparisonTests.java
index f8b21d3..a35986f 100644
--- a/src/test/java/net/fabricmc/meta/test/integration/ComparisonTests.java
+++ b/src/test/java/net/fabricmc/meta/test/integration/ComparisonTests.java
@@ -32,7 +32,7 @@
 import org.junit.jupiter.params.provider.MethodSource;
 
 import net.fabricmc.meta.FabricMeta;
-import net.fabricmc.meta.web.WebServer;
+import net.fabricmc.meta.test.TestUtils;
 
 // Tests that the local response matches the remote response of the version in prod
 public class ComparisonTests {
@@ -79,7 +79,7 @@ public static Stream provideEndpoints() {
 	@ParameterizedTest
 	@MethodSource("provideEndpoints")
 	void compareEndpoint(String endpoint) {
-		JavalinTest.test(WebServer.create(), (server, client) -> {
+		JavalinTest.test(TestUtils.createServer(), (server, client) -> {
 			compareEndpoint(endpoint, client);
 		});
 	}
diff --git a/src/test/java/net/fabricmc/meta/test/unit/EndpointsV2Tests.java b/src/test/java/net/fabricmc/meta/test/unit/EndpointsV2Tests.java
index 915000d..d3d3a78 100644
--- a/src/test/java/net/fabricmc/meta/test/unit/EndpointsV2Tests.java
+++ b/src/test/java/net/fabricmc/meta/test/unit/EndpointsV2Tests.java
@@ -24,7 +24,7 @@
 import org.junit.jupiter.api.Test;
 
 import net.fabricmc.meta.FabricMeta;
-import net.fabricmc.meta.web.WebServer;
+import net.fabricmc.meta.test.TestUtils;
 
 public class EndpointsV2Tests {
 	@BeforeAll
@@ -35,7 +35,7 @@ static void beforeAll() {
 
 	@Test
 	void versions() {
-		JavalinTest.test(WebServer.create(), (server, client) -> {
+		JavalinTest.test(TestUtils.createServer(), (server, client) -> {
 			Response response = client.get("/v2/versions");
 			assertEquals(200, response.code());
 			String body = response.body().string();
diff --git a/src/test/java/net/fabricmc/meta/test/unit/ServerBootstrapTests.java b/src/test/java/net/fabricmc/meta/test/unit/ServerBootstrapTests.java
index 91bb015..261f45a 100644
--- a/src/test/java/net/fabricmc/meta/test/unit/ServerBootstrapTests.java
+++ b/src/test/java/net/fabricmc/meta/test/unit/ServerBootstrapTests.java
@@ -29,7 +29,7 @@
 import org.junit.jupiter.api.io.TempDir;
 
 import net.fabricmc.meta.FabricMeta;
-import net.fabricmc.meta.web.WebServer;
+import net.fabricmc.meta.test.TestUtils;
 
 public class ServerBootstrapTests {
 	@TempDir
@@ -43,7 +43,7 @@ static void beforeAll() {
 
 	@Test
 	void serverJar() {
-		JavalinTest.test(WebServer.create(), (server, client) -> {
+		JavalinTest.test(TestUtils.createServer(), (server, client) -> {
 			Response response = client.get("/v2/versions/loader/stable/stable/stable/server/jar");
 			assertEquals(200, response.code());
 			Path jarFile = tempDir.resolve("server.jar");