diff --git a/app/assets/javascripts/glql/components/presenters/table.vue b/app/assets/javascripts/glql/components/presenters/table.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f6dc575258394494f5a5da53527245f1748742f0
--- /dev/null
+++ b/app/assets/javascripts/glql/components/presenters/table.vue
@@ -0,0 +1,60 @@
+<script>
+import { toSentenceCase } from '../../utils/common';
+
+export default {
+  name: 'TablePresenter',
+  inject: ['presenter'],
+  props: {
+    data: {
+      required: true,
+      type: Object,
+      validator: ({ nodes }) => Array.isArray(nodes),
+    },
+    config: {
+      required: true,
+      type: Object,
+      validator: ({ fields }) => Array.isArray(fields) && fields.length > 0,
+    },
+  },
+  computed: {
+    items() {
+      return this.data.nodes;
+    },
+    fields() {
+      return this.config.fields.map((field) => {
+        return { key: field, label: toSentenceCase(field) };
+      });
+    },
+  },
+};
+</script>
+<template>
+  <div class="!gl-my-4">
+    <table class="!gl-mt-0 !gl-mb-2">
+      <thead>
+        <tr>
+          <th v-for="field in fields" :key="field.key">
+            {{ field.label }}
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr
+          v-for="(item, itemIndex) in items"
+          :key="itemIndex"
+          :data-testid="`table-row-${itemIndex}`"
+        >
+          <td v-for="(field, fieldIndex) in fields" :key="fieldIndex">
+            <component :is="presenter.forField(item, field.key)" />
+          </td>
+        </tr>
+        <tr v-if="!items.length">
+          <td :colspan="fields.length" class="gl-text-center">
+            <em>{{ __('No data found for this query') }}</em>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+    <small>{{ __('Generated by GLQL') }}</small>
+  </div>
+</template>
diff --git a/app/assets/javascripts/glql/core/presenter.js b/app/assets/javascripts/glql/core/presenter.js
index bbcf8fc632e9a5071360f55ae00b76ea2149e13f..d001de87de6adb15015f642b581a1bd57cba51d9 100644
--- a/app/assets/javascripts/glql/core/presenter.js
+++ b/app/assets/javascripts/glql/core/presenter.js
@@ -3,10 +3,13 @@ import LinkPresenter from '../components/presenters/link.vue';
 import TextPresenter from '../components/presenters/text.vue';
 import ListPresenter from '../components/presenters/list.vue';
 import NullPresenter from '../components/presenters/null.vue';
+import TablePresenter from '../components/presenters/table.vue';
 
 const presentersByDisplayType = {
   list: ListPresenter,
   orderedList: ListPresenter,
+
+  table: TablePresenter,
 };
 
 const olProps = { listType: 'ol' };
diff --git a/app/assets/javascripts/glql/utils/common.js b/app/assets/javascripts/glql/utils/common.js
index 58006820b5ec91f864f2a03ccdbd4c0cc8c2a443..14302e3905308f52529684f28f38884b8dfde07a 100644
--- a/app/assets/javascripts/glql/utils/common.js
+++ b/app/assets/javascripts/glql/utils/common.js
@@ -1,8 +1,7 @@
 import jsYaml from 'js-yaml';
-import { uniq } from 'lodash';
+import { uniq, upperFirst, lowerCase } from 'lodash';
 
-export const extractGroupOrProject = () => {
-  const url = window.location.href;
+export const extractGroupOrProject = (url = window.location.href) => {
   let fullPath = url
     .replace(window.location.origin, '')
     .split('/-/')[0]
@@ -31,3 +30,8 @@ export const parseFrontmatter = (frontmatter, defaults = {}) => {
   config.display = config.display || 'list';
   return config;
 };
+
+export const toSentenceCase = (str) => {
+  if (str === 'id' || str === 'iid') return str.toUpperCase();
+  return upperFirst(lowerCase(str));
+};
diff --git a/spec/frontend/glql/components/presenters/table_spec.js b/spec/frontend/glql/components/presenters/table_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..4639735b7c6a5edd570211be2591fd2cc2584d6c
--- /dev/null
+++ b/spec/frontend/glql/components/presenters/table_spec.js
@@ -0,0 +1,62 @@
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+import TablePresenter from '~/glql/components/presenters/table.vue';
+import TextPresenter from '~/glql/components/presenters/text.vue';
+import LinkPresenter from '~/glql/components/presenters/link.vue';
+import Presenter from '~/glql/core/presenter';
+import { MOCK_ISSUES, MOCK_FIELDS } from '../../mock_data';
+
+describe('TablePresenter', () => {
+  let wrapper;
+
+  const createWrapper = ({ data, config, ...moreProps }, mountFn = shallowMountExtended) => {
+    wrapper = mountFn(TablePresenter, {
+      provide: {
+        presenter: new Presenter().init({ data, config }),
+      },
+      propsData: { data, config, ...moreProps },
+    });
+  };
+
+  it('renders header rows with sentence cased field names', () => {
+    createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } });
+
+    const headerRow = wrapper.find('thead tr');
+    const headerCells = headerRow.findAll('th').wrappers.map((th) => th.text());
+
+    expect(headerCells).toEqual(['Title', 'Author', 'State']);
+  });
+
+  it('renders a row of items presented by appropriate presenters', () => {
+    createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }, mountExtended);
+
+    const tableRow1 = wrapper.findByTestId('table-row-0');
+    const tableRow2 = wrapper.findByTestId('table-row-1');
+
+    const linkPresenters1 = tableRow1.findAllComponents(LinkPresenter);
+    const linkPresenters2 = tableRow2.findAllComponents(LinkPresenter);
+    const textPresenter1 = tableRow1.findComponent(TextPresenter);
+    const textPresenter2 = tableRow2.findComponent(TextPresenter);
+
+    expect(linkPresenters1).toHaveLength(2);
+    expect(linkPresenters2).toHaveLength(2);
+
+    expect(linkPresenters1.at(0).props('data')).toBe(MOCK_ISSUES.nodes[0]);
+    expect(linkPresenters1.at(1).props('data')).toBe(MOCK_ISSUES.nodes[0].author);
+    expect(linkPresenters2.at(0).props('data')).toBe(MOCK_ISSUES.nodes[1]);
+    expect(linkPresenters2.at(1).props('data')).toBe(MOCK_ISSUES.nodes[1].author);
+
+    expect(textPresenter1.props('data')).toBe(MOCK_ISSUES.nodes[0].state);
+    expect(textPresenter2.props('data')).toBe(MOCK_ISSUES.nodes[1].state);
+
+    const getCells = (row) => row.findAll('td').wrappers.map((td) => td.text());
+
+    expect(getCells(tableRow1)).toEqual(['Issue 1', 'foobar', 'opened']);
+    expect(getCells(tableRow2)).toEqual(['Issue 2', 'janedoe', 'closed']);
+  });
+
+  it('shows a "No data" message if the list of items provided is empty', () => {
+    createWrapper({ data: { nodes: [] }, config: { fields: MOCK_FIELDS } });
+
+    expect(wrapper.text()).toContain('No data found for this query');
+  });
+});
diff --git a/spec/frontend/glql/core/presenter_spec.js b/spec/frontend/glql/core/presenter_spec.js
index ca8197093b35ec1fdb61e8abbd33feb00db34a51..663676ee331a15856493e30d384f7d4e2e3650d9 100644
--- a/spec/frontend/glql/core/presenter_spec.js
+++ b/spec/frontend/glql/core/presenter_spec.js
@@ -2,6 +2,7 @@ import Presenter, { componentForField } from '~/glql/core/presenter';
 import LinkPresenter from '~/glql/components/presenters/link.vue';
 import TextPresenter from '~/glql/components/presenters/text.vue';
 import ListPresenter from '~/glql/components/presenters/list.vue';
+import TablePresenter from '~/glql/components/presenters/table.vue';
 import { mountExtended } from 'helpers/vue_test_utils_helper';
 import { MOCK_FIELDS, MOCK_ISSUES } from '../mock_data';
 
@@ -18,12 +19,13 @@ describe('componentForField', () => {
 
 describe('Presenter', () => {
   it.each`
-    displayType      | additionalProps
-    ${'list'}        | ${{ listType: 'ul' }}
-    ${'orderedList'} | ${{ listType: 'ol' }}
+    displayType      | additionalProps       | PresenterComponent
+    ${'list'}        | ${{ listType: 'ul' }} | ${ListPresenter}
+    ${'orderedList'} | ${{ listType: 'ol' }} | ${ListPresenter}
+    ${'table'}       | ${{}}                 | ${TablePresenter}
   `(
-    'inits a ListPresenter for displayType: $displayType with additionalProps: $additionalProps',
-    async ({ displayType, additionalProps }) => {
+    'inits appropriate presenter component for displayType: $displayType with additionalProps: $additionalProps',
+    async ({ displayType, additionalProps, PresenterComponent }) => {
       const element = document.createElement('div');
       element.innerHTML =
         '<pre><code data-canonical-lang="glql">assignee = currentUser()</code></pre>';
@@ -32,12 +34,12 @@ describe('Presenter', () => {
 
       const { component } = await new Presenter().init({ data, config });
       const wrapper = mountExtended(component);
-      const listPresenter = wrapper.findComponent(ListPresenter);
+      const presenter = wrapper.findComponent(PresenterComponent);
 
-      expect(listPresenter.exists()).toBe(true);
-      expect(listPresenter.props('data')).toBe(data);
-      expect(listPresenter.props('config')).toBe(config);
-      expect(listPresenter.props()).toMatchObject(additionalProps);
+      expect(presenter.exists()).toBe(true);
+      expect(presenter.props('data')).toBe(data);
+      expect(presenter.props('config')).toBe(config);
+      expect(presenter.props()).toMatchObject(additionalProps);
     },
   );
 });
diff --git a/spec/frontend/glql/utils/common_spec.js b/spec/frontend/glql/utils/common_spec.js
index 0af6547c460018115465eb2eaeeac468452514ea..949afa5567236a53b82db3ce2b303ca5499b2850 100644
--- a/spec/frontend/glql/utils/common_spec.js
+++ b/spec/frontend/glql/utils/common_spec.js
@@ -1,4 +1,9 @@
-import { extractGroupOrProject, parseQueryText, parseFrontmatter } from '~/glql/utils/common';
+import {
+  extractGroupOrProject,
+  parseQueryText,
+  parseFrontmatter,
+  toSentenceCase,
+} from '~/glql/utils/common';
 import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
 
 describe('extractGroupOrProject', () => {
@@ -72,3 +77,16 @@ describe('parseFrontmatter', () => {
     });
   });
 });
+
+describe('toSentenceCase', () => {
+  it.each`
+    str                     | expected
+    ${'title'}              | ${'Title'}
+    ${'camelCasedExample'}  | ${'Camel cased example'}
+    ${'snake_case_example'} | ${'Snake case example'}
+    ${'id'}                 | ${'ID'}
+    ${'iid'}                | ${'IID'}
+  `('returns $expected for $str', ({ str, expected }) => {
+    expect(toSentenceCase(str)).toBe(expected);
+  });
+});