diff --git a/spec/pwa_test.js b/spec/pwa_test.js
new file mode 100644
index 0000000000000000000000000000000000000000..57375faa48cef24eeac5a62ab84454cc27e14d81
--- /dev/null
+++ b/spec/pwa_test.js
@@ -0,0 +1,358 @@
+import request from 'supertest'
+import { expect } from 'chai'
+import * as td from 'testdouble'
+import { mockApp, mockConfig, mockFetch, mockRedis } from './util.js'
+
+describe('Service delivers a generated web-manifest', function () {
+  before(async function () {
+    mockConfig({ urls: ['http://ui-server/'] })
+    mockRedis()
+  })
+
+  after(async function () {
+    td.reset()
+  })
+
+  afterEach(async function () {
+    await import('../src/redis.js').then(({ client }) => client.flushdb())
+    await import('../src/cache.js').then(({ clear }) => clear())
+  })
+
+  it('delivers valid webmanifest with short syntax', async function () {
+    const app = await mockApp()
+    mockFetch({
+      'https://ui-server': {
+        '/api/apps/manifests': {
+          data: {
+            capabilities: [],
+            host: 'all',
+            productName: 'Valid App Suite',
+            pwa: {
+              enabled: true,
+              name: 'Valid App Suite',
+              shortName: 'Valid App Suite',
+              icon: '/themes/default/logo_512.png',
+              iconWidthHeight: 512,
+              backgroundColor: 'white'
+            }
+          }
+        }
+      }
+    })
+    const response = await request(app.server).get('/pwa.json').set('host', 'ui-server')
+    expect(response.statusCode).to.equal(200)
+    expect(response.body).to.deep.include({
+      name: 'Valid App Suite',
+      short_name: 'Valid App Suite',
+      icons: [
+        {
+          src: '/themes/default/logo_512.png',
+          type: 'image/png',
+          sizes: '512x512',
+          purpose: 'any'
+        }
+      ],
+      background_color: 'white'
+    })
+  })
+
+  it('delivers no manifest with pwa.enabled=false', async function () {
+    const app = await mockApp()
+    mockFetch({
+      'https://ui-server': {
+        '/api/apps/manifests': {
+          data: {
+            capabilities: [],
+            host: 'all',
+            productName: 'Pwa not enabled'
+          }
+        }
+      }
+    })
+    const response = await request(app.server).get('/pwa.json').set('host', 'ui-server')
+    expect(response.statusCode).to.equal(200)
+    expect(response.body).to.deep.equal({})
+  })
+
+  it('delivers valid webmanifest with minimal properties', async function () {
+    const app = await mockApp()
+    mockFetch({
+      'https://ui-server': {
+        '/api/apps/manifests': {
+          data: {
+            capabilities: [],
+            host: 'all',
+            productName: 'Valid App Suite',
+            pwa: {
+              enabled: true,
+              shortName: 'Short Name',
+              icon: '/themes/default/logo_512.png',
+              iconWidthHeight: 512
+            }
+          }
+        }
+      }
+    })
+    const response = await request(app.server).get('/pwa.json').set('host', 'ui-server')
+    expect(response.statusCode).to.equal(200)
+    expect(response.body).to.deep.include({
+      name: 'Short Name',
+      short_name: 'Short Name',
+      icons: [
+        {
+          src: '/themes/default/logo_512.png',
+          type: 'image/png',
+          sizes: '512x512',
+          purpose: 'any'
+        }
+      ],
+      background_color: 'white'
+    })
+  })
+
+  it('must not deliver an invalid manifest when using the short syntax', async function () {
+    const app = await mockApp()
+    mockFetch({
+      'https://ui-server': {
+        '/api/apps/manifests': {
+          data: {
+            capabilities: [],
+            host: 'all',
+            productName: 'Invalid App Suite',
+            pwa: {
+              enabled: true,
+              name: '123',
+              shortName: true,
+              icon: '/themes/default/logo_512.png',
+              iconWidthHeight: 'noNumbers',
+              backgroundColor: 'hello'
+            }
+          }
+        }
+      }
+    })
+    const response = await request(app.server).get('/pwa.json').set('host', 'ui-server')
+    expect(response.statusCode).to.equal(500)
+    expect(response.text).to.have.string('Failed to load config for url ui-server: Error:')
+  })
+
+  it('must not deliver a manifest with invalid host', async function () {
+    const app = await mockApp()
+    const response = await request(app.server).get('/pwa.json').set('host', 'ui-server-not')
+    expect(response.statusCode).to.equal(500)
+    expect(response.text).to.equal('Failed to load config for url ui-server-not: Error: Failed to fetch https://ui-server-not/api/apps/manifests?action=config')
+  })
+
+  it('delivers valid webmanifest with raw_manifest', async function () {
+    const app = await mockApp()
+    mockFetch({
+      'https://ui-server': {
+        '/api/apps/manifests': {
+          data: {
+            capabilities: [],
+            host: 'all',
+            productName: 'Valid App Suite',
+            pwa: {
+              enabled: true,
+              raw_manifest: {
+                name: 'Valid App Suite',
+                short_name: 'Valid App Suite',
+                icons: [
+                  {
+                    src: '/themes/default/logo_512.png',
+                    type: 'image/png',
+                    sizes: '512x512',
+                    purpose: 'any'
+                  }
+                ],
+                theme_color: 'white'
+              }
+            }
+          }
+        }
+      }
+    })
+    const response = await request(app.server).get('/pwa.json').set('host', 'ui-server')
+    expect(response.statusCode).to.equal(200)
+    expect(response.body).to.deep.include({
+      name: 'Valid App Suite',
+      short_name: 'Valid App Suite',
+      icons: [
+        {
+          src: '/themes/default/logo_512.png',
+          type: 'image/png',
+          sizes: '512x512',
+          purpose: 'any'
+        }
+      ],
+      theme_color: 'white'
+    })
+  })
+
+  it('must not deliver an invalid manifest with raw_manifest', async function () {
+    const app = await mockApp()
+    mockFetch({
+      'https://ui-server': {
+        '/api/apps/manifests': {
+          data: {
+            capabilities: [],
+            host: 'all',
+            productName: 'Invalid App Suite',
+            pwa: {
+              enabled: true,
+              raw_manifest: {
+                name: 123,
+                shortName: 'Invalid App Suite',
+                icons: [
+                  {
+                    type: 'image/fff',
+                    sizes: 'noNumbers',
+                    purpose: 'any'
+                  }
+                ],
+                theme_color: 'hello'
+              }
+            }
+          }
+        }
+      }
+    })
+    const response = await request(app.server).get('/pwa.json').set('host', 'ui-server')
+    expect(response.statusCode).to.equal(500)
+    expect(response.text).to.have.string('Failed to load config for url ui-server: Error:')
+  })
+
+  it('must choose raw_manifest over short syntax', async function () {
+    const app = await mockApp()
+    mockFetch({
+      'https://ui-server': {
+        '/api/apps/manifests': {
+          data: {
+            capabilities: [],
+            host: 'all',
+            productName: 'Invalid App Suite',
+            pwa: {
+              enabled: true,
+              name: 'Short Syntax',
+              shortName: 'Short Syntax',
+              icon: '/themes/default/logo_512.png',
+              iconWidthHeight: 512,
+              backgroundColor: 'white',
+              raw_manifest: {
+                name: 'Raw Manifest',
+                short_name: 'raw_manifest',
+                icons: [
+                  {
+                    src: '/themes/default/logo_512.png',
+                    type: 'image/fff',
+                    sizes: '123x123',
+                    purpose: 'any'
+                  }
+                ],
+                theme_color: 'hello'
+              }
+            }
+          }
+        }
+      }
+    })
+    const response = await request(app.server).get('/pwa.json').set('host', 'ui-server')
+    expect(response.statusCode).to.equal(200)
+    expect(response.body).to.deep.equal({
+      name: 'Raw Manifest',
+      short_name: 'raw_manifest',
+      icons: [
+        {
+          src: '/themes/default/logo_512.png',
+          type: 'image/fff',
+          sizes: '123x123',
+          purpose: 'any'
+        }
+      ],
+      theme_color: 'hello'
+    })
+  })
+
+  it('differ between two hosts', async function () {
+    const app = await mockApp()
+    mockFetch({
+      'https://ui-server': {
+        '/api/apps/manifests': {
+          data: {
+            capabilities: [],
+            host: 'all',
+            productName: 'Invalid App Suite',
+            pwa: {
+              enabled: true
+            }
+          }
+        }
+      },
+      'https://ui-server-other': {
+        '/api/apps/manifests': {
+          data: {
+            capabilities: [],
+            host: 'all',
+            productName: 'Invalid App Suite',
+            pwa: {
+              enabled: true,
+              shortName: 'Other Suite'
+            }
+          }
+        }
+      }
+    })
+    const response = await request(app.server).get('/pwa.json').set('host', 'ui-server')
+    const responseOther = await request(app.server).get('/pwa.json').set('host', 'ui-server-other')
+    expect(response.statusCode).to.equal(200)
+    expect(response.body).to.deep.equal({
+      name: 'OX App Suite',
+      short_name: 'OX App Suite',
+      icons: [
+        {
+          src: '/themes/default/logo_512.png',
+          type: 'image/png',
+          sizes: '512x512',
+          purpose: 'any'
+        }
+      ],
+      theme_color: 'white',
+      start_url: '/#pwa=true',
+      display: 'standalone',
+      background_color: 'white',
+      scope: '/',
+      id: '/#pwa=true',
+      protocol_handlers: [
+        {
+          protocol: 'mailto',
+          url: '/#app=io.ox/mail&mailto=%s'
+        }
+      ]
+    })
+    expect(responseOther.statusCode).to.equal(200)
+    expect(responseOther.body).to.deep.equal({
+      name: 'Other Suite',
+      short_name: 'Other Suite',
+      icons: [
+        {
+          src: '/themes/default/logo_512.png',
+          type: 'image/png',
+          sizes: '512x512',
+          purpose: 'any'
+        }
+      ],
+      theme_color: 'white',
+      start_url: '/#pwa=true',
+      display: 'standalone',
+      background_color: 'white',
+      scope: '/',
+      id: '/#pwa=true',
+      protocol_handlers: [
+        {
+          protocol: 'mailto',
+          url: '/#app=io.ox/mail&mailto=%s'
+        }
+      ]
+    })
+  })
+})
diff --git a/src/cache.js b/src/cache.js
index bb56d038833675c1d75e6765cd5de110f0c4ded8..fc79aca007084c93dc5b8d187c9055a8ec2a2e01 100644
--- a/src/cache.js
+++ b/src/cache.js
@@ -11,12 +11,18 @@ export const fileCacheSizeGauge = new Gauge({
   help: 'Number of entries in file cache'
 })
 
-export function set (key, value) {
+function logAndIgnoreError (err) {
+  logger.error(err)
+}
+
+export function set (key, value, timeout) {
   logger.debug(`[Cache] Set ${key}`)
   if (cache[key] === value) return
   cache[key] = value
+  if (timeout) setTimeout(expire, timeout * 1000, key)
   if (redis.isEnabled()) {
-    return redis.client.set(key, value).catch(err => logger.error(err))
+    if (timeout) return redis.client.set(key, value, 'EX', timeout).catch(logAndIgnoreError)
+    return redis.client.set(key, value).catch(logAndIgnoreError)
   }
 }
 
@@ -40,17 +46,21 @@ export function get (key, fallback) {
         logger.debug(`[Cache] Resolve from redis: ${key}`)
         result = JSON.parse(result)
         cache[key] = result
+        redis.client.ttl(key).then(timeout => {
+          if (timeout > 0) setTimeout(expire, timeout * 1000, key)
+        })
         return result
       }
     }
 
     if (!fallback) return
 
-    const fallbackResult = await fallback()
+    const [fallbackResult, timeout] = await fallback()
     if (fallbackResult) {
       logger.debug(`[Cache] Found a getter for: ${key}`)
+      set(key, JSON.stringify(fallbackResult), timeout)
+      // overwrite local cache again, as set() will store the stringified version
       cache[key] = fallbackResult
-      if (redis.isEnabled()) redis.client.set(key, JSON.stringify(fallbackResult)).catch(err => logger.error(err))
     }
     return fallbackResult
   })()
@@ -113,3 +123,8 @@ export function getFile ({ name, version }, fallback) {
   cache[key] = promise
   return promise
 }
+
+function expire (key) {
+  logger.debug(`[Cache] Key ${key} has expired.`)
+  delete cache[key]
+}
diff --git a/src/create-app.js b/src/create-app.js
index f565cc89a9a3871ef3b6615505223a7695862348..398105dbe7b54850e5dd4e9d4416021d65988f38 100644
--- a/src/create-app.js
+++ b/src/create-app.js
@@ -12,6 +12,7 @@ import manifestsPlugin from './plugins/manifests.js'
 import metadataPlugin from './plugins/metadata.js'
 import redirectsPlugin from './plugins/redirects.js'
 import serveFilePlugin from './plugins/serve-files.js'
+import serveWebmanifest from './plugins/webmanifest.js'
 import { logger } from './logger.js'
 
 const swaggerDocument = yaml.load(fs.readFileSync('./src/swagger.yaml', 'utf8'))
@@ -79,6 +80,7 @@ export async function createApp (basePath) {
   }
 
   await app.register(serveFilePlugin)
+  await app.register(serveWebmanifest)
 
   return app
 }
diff --git a/src/manifests.js b/src/manifests.js
index 209a7269f19052f96a96ff4da5ff390f2cc80011..b59c8ffd98d2f08ac3e33a10f91f2cfb6dfdbd72 100644
--- a/src/manifests.js
+++ b/src/manifests.js
@@ -31,7 +31,7 @@ export async function fetchViteManifests () {
 }
 
 export async function getViteManifests ({ version }) {
-  return cache.get(getRedisKey({ version, name: 'viteManifests' }), () => fetchViteManifests())
+  return cache.get(getRedisKey({ version, name: 'viteManifests' }), async () => [await fetchViteManifests()])
 }
 
 export function getOxManifests ({ version }) {
@@ -50,7 +50,7 @@ export function getOxManifests ({ version }) {
 export function getDependencies ({ version }) {
   return cache.get(getRedisKey({ version, name: 'dependencies' }), async () => {
     const viteManifests = await getViteManifests({ version })
-    return viteManifestToDeps(viteManifests)
+    return [viteManifestToDeps(viteManifests)]
   })
 }
 
diff --git a/src/meta.js b/src/meta.js
index c6d5761795570d00fcece6ff9c6fa2a5a16d7cee..17adfb27e691eeef0e7cab4ac82fa17747ec6c50 100644
--- a/src/meta.js
+++ b/src/meta.js
@@ -27,5 +27,5 @@ export async function fetchMergedMetadata () {
 }
 
 export function getMergedMetadata ({ version }) {
-  return cache.get(getRedisKey({ version, name: 'mergedMetadata' }), () => fetchMergedMetadata())
+  return cache.get(getRedisKey({ version, name: 'mergedMetadata' }), async () => [await fetchMergedMetadata()])
 }
diff --git a/src/plugins/webmanifest.js b/src/plugins/webmanifest.js
new file mode 100644
index 0000000000000000000000000000000000000000..682f76ca1e9bc1e789f53997a5bb0318ea104bd2
--- /dev/null
+++ b/src/plugins/webmanifest.js
@@ -0,0 +1,104 @@
+import { get } from '../cache.js'
+import Validator from '../validator.js'
+import { getRedisKey } from '../util.js'
+
+const template = {
+  // custom values
+  name: 'OX App Suite',
+  short_name: 'OX App Suite',
+  icons: [
+    {
+      src: '/themes/default/logo_512.png',
+      type: 'image/png',
+      sizes: '512x512',
+      purpose: 'any'
+    }
+  ],
+  // fixed values
+  // theme_color is taken from index.html and is changed by the theme
+  theme_color: 'white',
+  start_url: '/#pwa=true',
+  display: 'standalone',
+  background_color: 'white',
+  scope: '/',
+  id: '/#pwa=true',
+  protocol_handlers: [
+    {
+      protocol: 'mailto',
+      url: '/#app=io.ox/mail&mailto=%s'
+    }
+  ]
+}
+
+export default async function serveWebmanifest (fastify) {
+  fastify.get('/pwa.json', async (req, res) => {
+    const hostname = req.hostname
+    try {
+      const cached = await get(getRedisKey({ name: `cachedManifest:${hostname}` }), async () => [await fetchWebManifest(hostname), 86400])
+      res.type('application/manifest+json')
+      res.send(cached)
+    } catch (err) {
+      res.statusCode = 500
+      res.send(`Failed to load config for url ${hostname}: ${err}`)
+    }
+  })
+}
+
+async function fetchWebManifest (hostname) {
+  const url = new URL('/api/apps/manifests?action=config', 'https://' + hostname)
+  const conf = await fetch(url)
+
+  if (conf.ok) {
+    const data = (await conf.json()).data
+    if (String(data.pwa?.enabled) !== 'true') return {}
+
+    const combinedManifest = data.pwa.raw_manifest || Object.assign({}, template, buildManifestFromData(data.pwa))
+    const valid = Validator.validate('https://json.schemastore.org/web-manifest-combined.json', combinedManifest)
+    if (!valid) {
+      throw new Error(JSON.stringify(Validator.errors[0], null, 2))
+    }
+    const webmanifest = JSON.stringify(combinedManifest, null, 2)
+    return webmanifest
+  } else {
+    throw new Error(`Failed to fetch ${url}`)
+  }
+}
+
+function buildManifestFromData (userData) {
+  const manifestData = {}
+
+  if (userData.icons) {
+    manifestData.icons = [{
+      src: userData.icon,
+      type: getTypeFromPath(userData.icon),
+      sizes: `${userData.iconWidthHeight}x${userData.iconWidthHeight}`,
+      purpose: 'any'
+    }]
+  }
+
+  if (userData.shortName) {
+    manifestData.short_name = userData.shortName
+    if (!userData.name) manifestData.name = userData.shortName
+  }
+
+  if (userData.name) manifestData.name = userData.name
+
+  if (userData.backgroundColor) {
+    manifestData.background_color = userData.backgroundColor
+    manifestData.theme_color = userData.backgroundColor
+  }
+
+  return manifestData
+}
+
+function getTypeFromPath (path) {
+  const ext = path.split('.').pop()
+  switch (ext) {
+    case 'png':
+      return 'image/png'
+    case 'svg':
+      return 'image/svg+xml'
+    default:
+      throw new Error('Unsupported file type or no file type.')
+  }
+}
diff --git a/src/redis.js b/src/redis.js
index 9286d086c4f013731fba08925e1e42ec521cc94f..8474035a6d37cff14043071b88331a0f9a6add53 100644
--- a/src/redis.js
+++ b/src/redis.js
@@ -18,6 +18,7 @@ const createClient = (type, options = {}) => {
       publish () {},
       subscribe () {},
       on () {},
+      async ttl () { return -1 },
       quit () { }
     }, {
       get () {
diff --git a/src/schemas/d7-manifest-app-info.json b/src/schemas/d7-manifest-app-info.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ae253ba2c3a1c0a590188c41c246e7d52f50891
--- /dev/null
+++ b/src/schemas/d7-manifest-app-info.json
@@ -0,0 +1,30 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "https://json.schemastore.org/web-manifest-app-info.json",
+  "properties": {
+    "categories": {
+      "description": "Describes the expected application categories to which the web application belongs.",
+      "type": "array",
+      "items": {
+        "type": "string"
+      }
+    },
+    "description": {
+      "description": "Description of the purpose of the web application",
+      "type": "string"
+    },
+    "iarc_rating_id": {
+      "description": "Represents an ID value of the IARC rating of the web application. It is intended to be used to determine which ages the web application is appropriate for.",
+      "type": "string"
+    },
+    "screenshots": {
+      "description": "The screenshots member is an array of image objects represent the web application in common usage scenarios.",
+      "type": "array",
+      "items": {
+        "$ref": "https://json.schemastore.org/web-manifest.json#/definitions/manifest_image_resource"
+      }
+    }
+  },
+  "title": "JSON schema for Web Application manifest files with app information extensions",
+  "type": "object"
+}
\ No newline at end of file
diff --git a/src/schemas/d7-manifest-combined.json b/src/schemas/d7-manifest-combined.json
new file mode 100644
index 0000000000000000000000000000000000000000..ed4019508c42684e71a0c32858c1a9db1fdafe89
--- /dev/null
+++ b/src/schemas/d7-manifest-combined.json
@@ -0,0 +1,16 @@
+{
+  "$id": "https://json.schemastore.org/web-manifest-combined.json",
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "allOf": [
+    {
+      "$ref": "https://json.schemastore.org/web-manifest.json"
+    },
+    {
+      "$ref": "https://json.schemastore.org/web-manifest-app-info.json"
+    },
+    {
+      "$ref": "https://json.schemastore.org/web-manifest-share-target.json"
+    }
+  ],
+  "title": "JSON schema for Web Application manifest files"
+}
\ No newline at end of file
diff --git a/src/schemas/d7-manifest-share-target.json b/src/schemas/d7-manifest-share-target.json
new file mode 100644
index 0000000000000000000000000000000000000000..06421dc08f7d1546661764b009f7173ddd52950d
--- /dev/null
+++ b/src/schemas/d7-manifest-share-target.json
@@ -0,0 +1,116 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "definitions": {
+    "share_target": {
+      "type": "object",
+      "description": "Describes how the application receives share data.",
+      "properties": {
+        "action": {
+          "description": "The URL for the web share target.",
+          "type": "string"
+        },
+        "method": {
+          "description": "The HTTP request method for the web share target.",
+          "type": "string",
+          "enum": [
+            "GET",
+            "POST",
+            "get",
+            "post"
+          ],
+          "default": "GET"
+        },
+        "enctype": {
+          "description": "This member specifies the encoding in the share request.",
+          "type": "string",
+          "enum": [
+            "application/x-www-form-urlencoded",
+            "multipart/form-data",
+            "APPLICATION/X-WWW-FORM-URLENCODED",
+            "MULTIPART/FORM-DATA"
+          ],
+          "default": "application/x-www-form-urlencoded"
+        },
+        "params": {
+          "description": "Specifies what data gets shared in the request.",
+          "$ref": "#/definitions/share_target_params"
+        }
+      },
+      "required": [
+        "action",
+        "params"
+      ]
+    },
+    "share_target_params": {
+      "type": "object",
+      "description": "Specifies what data gets shared in the request.",
+      "properties": {
+        "title": {
+          "description": "The name of the query parameter used for the title of the document being shared.",
+          "type": "string"
+        },
+        "text": {
+          "description": "The name of the query parameter used for the message body, made of arbitrary text.",
+          "type": "string"
+        },
+        "url": {
+          "description": "The name of the query parameter used for the URL string referring to a resource being shared.",
+          "type": "string"
+        },
+        "files": {
+          "description": "Description of how the application receives files from share requests.",
+          "oneOf": [
+            {
+              "$ref": "#/definitions/share_target_files"
+            },
+            {
+              "type": "array",
+              "items": {
+                "$ref": "#/definitions/share_target_files"
+              }
+            }
+          ]
+        }
+      }
+    },
+    "share_target_files": {
+      "type": "object",
+      "description": "Description of how the application receives files from share requests.",
+      "properties": {
+        "name": {
+          "description": "The name of the form field used to share the files.",
+          "type": "string"
+        },
+        "accept": {
+          "description": "Sequence of accepted MIME types or file extensions can be shared to the application.",
+          "oneOf": [
+            {
+              "type": "string",
+              "pattern": "^((\\..*)|(.*/.*))$"
+            },
+            {
+              "type": "array",
+              "items": {
+                "type": "string",
+                "pattern": "^((\\..*)|(.*/.*))$"
+              }
+            }
+          ]
+        }
+      },
+      "required": [
+        "name",
+        "accept"
+      ]
+    }
+  },
+  "$id": "https://json.schemastore.org/web-manifest-share-target.json",
+  "properties": {
+    "share_target": {
+      "description": "Declares the application to be a web share target, and describes how it receives share data.",
+      "$ref": "#/definitions/share_target"
+    }
+  },
+  "title": "JSON schema for Web Application manifest files with Web Share Target and Web Share Target Level 2 extensions",
+  "type": "object"
+}
\ No newline at end of file
diff --git a/src/schemas/d7-manifest.json b/src/schemas/d7-manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..ecadf8981388ca6d30278989471898c04bbdc9d3
--- /dev/null
+++ b/src/schemas/d7-manifest.json
@@ -0,0 +1,227 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "definitions": {
+    "manifest_image_resource": {
+      "type": "object",
+      "properties": {
+        "sizes": {
+          "description": "The sizes member is a string consisting of an unordered set of unique space-separated tokens which are ASCII case-insensitive that represents the dimensions of an image for visual media.",
+          "oneOf": [
+            {
+              "type": "string",
+              "pattern": "^[0-9 x]+$"
+            },
+            {
+              "const": "any"
+            }
+          ]
+        },
+        "src": {
+          "description": "The src member of an image is a URL from which a user agent can fetch the icon's data.",
+          "type": "string"
+        },
+        "type": {
+          "description": "The type member of an image is a hint as to the media type of the image.",
+          "type": "string",
+          "pattern": "^[\\sa-z0-9\\-+;\\.=\\/]+$"
+        },
+        "purpose": {
+          "type": "string",
+          "enum": [
+            "monochrome",
+            "maskable",
+            "any",
+            "monochrome maskable",
+            "monochrome any",
+            "maskable monochrome",
+            "maskable any",
+            "any monochrome",
+            "any maskable",
+            "monochrome maskable any",
+            "monochrome any maskable",
+            "maskable monochrome any",
+            "maskable any monochrome",
+            "any monochrome maskable",
+            "any maskable monochrome"
+          ],
+          "default": "any"
+        }
+      },
+      "required": [
+        "src"
+      ]
+    },
+    "external_application_resource": {
+      "type": "object",
+      "properties": {
+        "platform": {
+          "description": "The platform it is associated to.",
+          "enum": [
+            "chrome_web_store",
+            "play",
+            "itunes",
+            "windows"
+          ]
+        },
+        "url": {
+          "description": "The URL where the application can be found.",
+          "type": "string",
+          "format": "uri"
+        },
+        "id": {
+          "description": "Information additional to the URL or instead of the URL, depending on the platform.",
+          "type": "string"
+        },
+        "min_version": {
+          "description": "Information about the minimum version of an application related to this web app.",
+          "type": "string"
+        },
+        "fingerprints": {
+          "description": "An array of fingerprint objects used for verifying the application.",
+          "type": "array",
+          "items": {
+            "type": "object",
+            "properties": {
+              "type": {
+                "type": "string"
+              },
+              "value": {
+                "type": "string"
+              }
+            }
+          }
+        }
+      },
+      "required": [
+        "platform"
+      ]
+    },
+    "shortcut_item": {
+      "type": "object",
+      "description": "A shortcut item represents a link to a key task or page within a web app. A user agent can use these values to assemble a context menu to be displayed by the operating system when a user engages with the web app's icon.",
+      "properties": {
+        "name": {
+          "description": "The name member of a shortcut item is a string that represents the name of the shortcut as it is usually displayed to the user in a context menu.",
+          "type": "string"
+        },
+        "short_name": {
+          "description": "The short_name member of a shortcut item is a string that represents a short version of the name of the shortcut. It is intended to be used where there is insufficient space to display the full name of the shortcut.",
+          "type": "string"
+        },
+        "description": {
+          "description": "The description member of a shortcut item is a string that allows the developer to describe the purpose of the shortcut.",
+          "type": "string"
+        },
+        "url": {
+          "description": "The url member of a shortcut item is a URL within scope of a processed manifest that opens when the associated shortcut is activated.",
+          "type": "string"
+        },
+        "icons": {
+          "description": "The icons member of a shortcut item serves as iconic representations of the shortcut in various contexts.",
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/manifest_image_resource"
+          }
+        }
+      },
+      "required": [
+        "name",
+        "url"
+      ]
+    }
+  },
+  "$id": "https://json.schemastore.org/web-manifest.json",
+  "properties": {
+    "background_color": {
+      "description": "The background_color member describes the expected background color of the web application.",
+      "type": "string"
+    },
+    "dir": {
+      "description": "The base direction of the manifest.",
+      "enum": [
+        "ltr",
+        "rtl",
+        "auto"
+      ],
+      "default": "auto"
+    },
+    "display": {
+      "description": "The item represents the developer's preferred display mode for the web application.",
+      "enum": [
+        "fullscreen",
+        "standalone",
+        "minimal-ui",
+        "browser"
+      ],
+      "default": "browser"
+    },
+    "icons": {
+      "description": "The icons member is an array of icon objects that can serve as iconic representations of the web application in various contexts.",
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/manifest_image_resource"
+      }
+    },
+    "lang": {
+      "description": "The primary language for the values of the manifest.",
+      "type": "string"
+    },
+    "name": {
+      "description": "The name of the web application.",
+      "type": "string"
+    },
+    "orientation": {
+      "description": "The orientation member is a string that serves as the default orientation for all  top-level browsing contexts of the web application.",
+      "enum": [
+        "any",
+        "natural",
+        "landscape",
+        "portrait",
+        "portrait-primary",
+        "portrait-secondary",
+        "landscape-primary",
+        "landscape-secondary"
+      ]
+    },
+    "prefer_related_applications": {
+      "description": "Boolean value that is used as a hint for the user agent to say that related applications should be preferred over the web application.",
+      "type": "boolean"
+    },
+    "related_applications": {
+      "description": "Array of application accessible to the underlying application platform that has a relationship with the web application.",
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/external_application_resource"
+      }
+    },
+    "scope": {
+      "description": "A string that represents the navigation scope of this web application's application context.",
+      "type": "string"
+    },
+    "short_name": {
+      "description": "A string that represents a short version of the name of the web application.",
+      "type": "string"
+    },
+    "shortcuts": {
+      "description": "Array of shortcut items that provide access to key tasks within a web application.",
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/shortcut_item"
+      }
+    },
+    "start_url": {
+      "description": "Represents the URL that the developer would prefer the user agent load when the user launches the web application.",
+      "type": "string"
+    },
+    "theme_color": {
+      "description": "The theme_color member serves as the default theme color for an application context.",
+      "type": "string"
+    },
+    "id": {
+      "description": "A string that represents the id of the web application.",
+      "type": "string"
+    }
+  },
+  "title": "JSON schema for Web Application manifest files",
+  "type": "object"
+}
\ No newline at end of file
diff --git a/src/validator.js b/src/validator.js
new file mode 100644
index 0000000000000000000000000000000000000000..54f42248db37c9354697c173b72f103c31cfaa68
--- /dev/null
+++ b/src/validator.js
@@ -0,0 +1,14 @@
+import Ajv from 'ajv'
+import fs from 'node:fs'
+
+const ajv = new Ajv({
+  allErrors: true
+})
+
+ajv.addSchema(
+  fs.readdirSync('src/schemas/', 'utf8').map(file => {
+    return JSON.parse(fs.readFileSync(`src/schemas/${file}`, 'utf8'))
+  })
+)
+
+export default ajv