Building an internal documentation website in three days using Contentful

How to use the fail-fast philosophy to find the right tools and setup to build a small, completely free and fast internal documentation website for your team.

I ended up building an internal documentation website using Contentful, Next.js and Vercel in three days

Step 2: Plan

Option 1: Cosmos DB and Azure functions with API Management for the back-end

Option 2: Contentful, Gatsby and Netflify

Throwing Gatsby out the door

Hello, Next.js

Step 3: Design

Documentation site layout

Step 4: Development

Redux for state management

Very basic use of state management
import { configureStore } from '@reduxjs/toolkit'
import pagesReducer from './reducers/pagesReducer'

const store = configureStore({
reducer: {
pages: pagesReducer,
}
});

export default store;
import { createSlice } from '@reduxjs/toolkit';

const pagesSlice = createSlice({
name: 'pages',
initialState: {
pages: []
},
reducers: {
addPages (state, action) {
state.pages = action.payload;
},
addPage (state, action) {
state.pages.push(action.payload);
},
updatePage (state, action) {
const pageIndex = state.pages.findIndex(page => page.sys.id === action.payload.sys.id)

if(pageIndex > -1) {
state.pages[pageIndex] = action.payload;
}
}
}
});

export const { addPages, addPage, updatePage } = pagesSlice.actions;

export default pagesSlice.reducer;
import { Provider } from 'react-redux';
import store from '../src/store/rootReducer';
// ...

const App = ({ Component, pageProps }) => {
return (
<Provider store={store}>
<Component {...pageProps} />
</Provider>
);
};
const pageState = useSelector(state => state.pages);

Contentful’s Content Management API

useEffect(() => {
const client = createClient({
accessToken: Cookies.get('token')
});

client.getSpace(process.env.CONTENTFUL_SPACE_ID)
.then((space) => space.getEntries({
content_type: 'page',
order: 'fields.order'
}))
.then((response) => {
dispatch(addPages(response.items));
})
.catch((e) => setError(e));
}, []);

Pitfalls to this approach

Final result

Additional page data, such as when the page was updated, is displayed on the right with action links.

Implementing Search with Contentful

const handleSearchInputChange = (e) => {
e.preventDefault();
e.persist();

let typingTimeout;
setOpen(false);

clearTimeout(typingTimeout);

const value = e.target.value;

const client = createClient({ accessToken: access_token });

if (value !== '' && value.length > 3) {
typingTimeout = setTimeout(function () {
client.getSpace(process.env.CONTENTFUL_SPACE_ID)
.then((space) => space.getEntries({
content_type: 'page',
query: e.target.value
}))
.then((response) => setResults(response.items))
.catch(console.error)
.finally(() => setOpen(true));
}, 500);
}
};
return (
<Search ref={ref}>
<input type="text" placeholder="Search..." onChange={handleSearchInputChange} onFocus={() => results ? setOpen(true) : null}/>
{!isEmpty(results) ? (
<SearchResults>
{results.map((entry: Entry) => {
let content = get(entry, 'fields.content[\'en-US\']', '');

if (content) {
content = htmlToText.fromString(content).substr(0, 64);
}

return open ? (
<SearchResult>
<Link href={'/page/[id]'} as={`/page/${entry.fields.slug['en-US']}`}>
<a onClick={() => setOpen(false)}>
<h4>{get(entry, 'fields.title[\'en-US\']', '')}</h4>
<p>{content}...</p>
</a>
</Link>
</SearchResult>
) : <></>;
})}
</SearchResults>
) : ('')}
</Search>
)

Implementing image upload to Contentful with React Draft Wysiwyg

<Editor
editorState={editorState}
toolbarClassName="wysiwyg-editor-toolbar"
wrapperClassName="wrapperClassName"
editorClassName="wysiwyg-editor"
onEditorStateChange={onEditorStateChange}
defaultContentState={get(entry, 'fields.content[\'en-US\']', '')}
toolbar={{
image: {
uploadCallback: handleImageUpload,
previewImage: true,
alt: { present: true, mandatory: false },
inputAccept: 'image/gif,image/jpeg,image/jpg,image/png,image/svg'
},
options: ['blockType', 'fontSize', 'inline', 'list', 'textAlign', 'link', 'image'],
inline: {
inDropdown: false,
options: ['bold', 'italic', 'underline', 'strikethrough']
},
link: { inDropdown: false }
}}
/>
const handleImageUpload = (file) => {
return new Promise(
(resolve, reject) => {
const client = createClient({
accessToken: Cookies.get('token')
});

client.getSpace(props.spaceId)
.then((space) => space.createAssetFromFiles({
fields: {
title: {
'en-US': file.name,
},
description: {
'en-US': file.name,
},
file: {
'en-US': {
contentType: file.type,
fileName: file.name,
file: file
}
}
}
}))
.then((asset) => asset.processForAllLocales())
.then((asset) => asset.publish())
.then((entry) => resolve({ data: { link: entry.fields.file['en-US'].url }}))
.catch((error => reject(error));
}
);
};
An image displayed in the editor at the Edit Page

Deployment

What now?

Full time software developer and architect. Freelance UI/UX designer. Computer engineer. Tech enthusiast. Father of three.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store