Video meetings, chat, phone calls, voicemail, calendar, and filesharing. This is roughly the app I spent the last couple of months rewriting from the ground up. The original project started before me with Twilio'sexample React app and it seems every change from there was just a litany of simple logic piled on top to force things to work. The architecture didn't match what we were trying to do. The Material UI implementation was putting custom layouts into a stranglehold. And don't get me started on conflicting CORS rules, and routing between an express node server and react-router.
I had the chance to take what was there and pull it into a new project. I removed Material UI completely (vanilla CSS is more scalable) and brought in Next.js as a framework.
I thought I would do my best to share some lessons learned during this process, what I did to improve things and what I would do differently in the future.
React is Not a Framework
React is a UI library. Not a framework. It's very good at what it does. Hooks are brilliant. But I don't believe it's realistic to build the frontend for a large complex app with only React. Especially when you're trying to do a lot of live or dynamic data with many integrations. (We'll get back to handling this stuff later.)
That's where Next.js comes in.
Next.js is a React framework. Complete with its own server to build an API and handle server-side rendering. As well as a brilliant Image component (that seems to be using web assembly these days), directory-based routing, data fetching tools and more. I would even argue that Next is a full-stack framework, albeit a small one.
If you're working with a well-written API that follows proper REST conventions, or better yet a GraphQL API and don't have a lot of client-side rendering, you can survive with pure React. But honestly, why bother with react-router and the headache of image tags? If you're starting a new React project I recommend always going Next.js.
Use Eslint
I created my own lint config for Next and TypeScript. It basically just extends... well... a lot of stuff. Critics may note that there's a lot of stuff overwriting a lot of stuff. And that is a valid criticism. But to me, the more hardcore the eslint config, the better. And with some tweaking to order and a few custom rules, this is working out for me.
Eslint can catch a lot of issues such as missing useEffect dependencies (note the useEffect article I link below), accessibility issues, unused imports, inefficient algorithms, etc. Hell, you can even use it to sort imports and alphabetically order object properties.
At a minimum, I would highly recommend the next configs, prettier, jsx-a11y, react-hooks, and typescript-eslint. But don't be afraid to get deeper and continuously tweak. Even create your own config that you can share between projects.
Use TypeScript
I can't tell you how many times TypeScript has saved my ass or made life easier. I would hate to write JavaScript without it. If you haven't gotten into TS yet, or you're still thinking, “It's extra typing for no benefit.” Go learn TypeScript right now. I recommend Stephen Grider's course.
With TypeScript, I created interfaces for any data calls to a REST API I made, got more comfortable with generics to make code more reusable and easier to read, created a pretty cool form generator using OOP style classes, and best of all, my Intellisense is totally superpowered.
1export const getFromLocalStorage = <Type>(
2 key: string,
3 fallbackValue?: Type
4): Type | undefined => {
5 const item = localStorage.getItem(key);
6 let returnValue = item as Type | null;
7
8 if (item) {
9 try {
10 returnValue = JSON.parse(item) as Type;
11 } catch {
12 returnValue = item as unknown as Type;
13 }
14 } else if (fallbackValue) {
15 if (typeof fallbackValue === 'string') {
16 localStorage.setItem(key, fallbackValue);
17 } else {
18 localStorage.setItem(key, JSON.stringify(fallbackValue));
19 }
20
21 return fallbackValue;
22 }
23
24 if (returnValue) {
25 return returnValue;
26 }
27
28 return undefined;
29};
1import { toCapitalizedWords } from '../../util/string';
2
3export enum InputTypes {
4 button = 'button',
5 checkbox = 'checkbox',
6 color = 'color',
7 date = 'date',
8 datetimeLocal = 'datetime-local',
9 email = 'email',
10 file = 'file',
11 hidden = 'hidden',
12 image = 'image',
13 month = 'month',
14 number = 'number',
15 password = 'password',
16 radio = 'radio',
17 range = 'range',
18 reset = 'reset',
19 search = 'search',
20 select = 'select',
21 submit = 'submit',
22 tel = 'tel',
23 text = 'text',
24 textarea = 'textarea',
25 time = 'time',
26 url = 'url',
27 week = 'week',
28}
29
30export enum AutoComplete {
31 additonalName = 'additional-name',
32 addressLevel1 = 'address-level1',
33 addressLevel2 = 'address-level2',
34 addressLevel3 = 'address-level3',
35 addressLevel4 = 'address-level4',
36 addressLine1 = 'address-line1',
37 addressLine2 = 'address-line2',
38 addressLine3 = 'address-line3',
39 bday = 'bday',
40 bdayDay = 'bday-day',
41 bdayMonth = 'bday-month',
42 bdayYear = 'bday-year',
43 ccAdditionalName = 'cc-additional-name',
44 ccExp = 'cc-exp',
45 ccExpMonth = 'cc-exp-month',
46 ccExpYear = 'cc-exp-year',
47 ccFamilyName = 'cc-family-name',
48 ccGivenName = 'cc-given-name',
49 ccName = 'cc-name',
50 ccNumber = 'cc-number',
51 ccType = 'cc-type',
52 cccsc = 'cc-csc',
53 country = 'country',
54 countryName = 'country-name',
55 currentPassword = 'current-password',
56 email = 'email',
57 familyName = 'family-name',
58 givenName = 'given-name',
59 honorificPrefix = 'honorific-prefix',
60 honorificSuffix = 'honorific-suffix',
61 impp = 'impp',
62 language = 'language',
63 name = 'name',
64 newPassword = 'new-password',
65 nickname = 'nickname',
66 off = 'off',
67 on = 'on',
68 oneTimeCode = 'one-time-code',
69 organization = 'organization',
70 organizationTitle = 'organization-title',
71 photo = 'photo',
72 postalCode = 'postal-code',
73 sex = 'sex',
74 streetAddress = 'street-address',
75 tel = 'tel',
76 telAreaCode = 'tel-area-code',
77 telCountryCode = 'tel-country-code',
78 telExtension = 'tel-extension',
79 telLocal = 'tel-local',
80 telNational = 'tel-national',
81 transactionAmount = 'transaction-amount',
82 transactionCurrency = 'transaction-currency',
83 url = 'url',
84 username = 'username',
85}
86
87type ConfigObject = {
88 autocomplete?: AutoComplete;
89 hideLabel?: boolean;
90 initialValue?: string | number;
91 placeHolder?: string;
92 required?: boolean;
93 selectOptions?: string[];
94 type?: InputTypes;
95 value?: string;
96};
97
98export class FormInput {
99 private _name: string;
100
101 private _displayName: string;
102
103 private _required?: boolean;
104
105 private _value?: string;
106
107 private _type?: InputTypes;
108
109 private _label?: string;
110
111 private _autocomplete?: AutoComplete;
112
113 private _id?: string;
114
115 private _placeHolder?: string;
116
117 private _initialValue?: string | number;
118
119 private _selectOptions?: string[];
120
121 private _hideLabel?: boolean;
122
123 constructor(name: string, configObject: ConfigObject = {}) {
124 this._name = name;
125 this._displayName = toCapitalizedWords(name);
126 this._label = name;
127 this._id = name;
128 this._type = configObject.type;
129 this._value = configObject.value;
130 this._required = configObject.required;
131 this._placeHolder = configObject.placeHolder;
132 this._initialValue = configObject.initialValue;
133 this._selectOptions = configObject.selectOptions;
134 this._hideLabel = configObject.hideLabel;
135 }
136
137 get type(): InputTypes {
138 if (undefined !== this._type) {
139 return this._type;
140 }
141
142 return InputTypes.text;
143 }
144
145 set type(value: InputTypes) {
146 this._type = value;
147 }
148
149 get displayName(): string {
150 return this._displayName;
151 }
152
153 set displayName(value: string) {
154 this._displayName = value;
155 }
156
157 get value(): string {
158 if (undefined !== this._value) {
159 return this._value;
160 }
161
162 return '';
163 }
164
165 set value(value: string) {
166 this._value = value;
167 }
168
169 get name(): string {
170 return this._name;
171 }
172
173 set name(value: string) {
174 this._name = value;
175 }
176
177 get label(): string {
178 if (undefined !== this._label) {
179 return this._label;
180 }
181
182 return '';
183 }
184
185 set label(value: string) {
186 this._label = toCapitalizedWords(value);
187 }
188
189 get id(): string {
190 if (undefined !== this._id) {
191 return this._id;
192 }
193
194 return '';
195 }
196
197 set id(value: string) {
198 this._id = value;
199 }
200
201 get autocomplete(): AutoComplete {
202 if (undefined !== this._autocomplete) {
203 return this._autocomplete;
204 }
205
206 return AutoComplete.on;
207 }
208
209 set autocomplete(value: AutoComplete) {
210 this._autocomplete = value;
211 }
212
213 get required(): boolean {
214 if (undefined !== this._required) {
215 return this._required;
216 }
217
218 return false;
219 }
220
221 set required(value: boolean) {
222 this._required = value;
223 }
224
225 get placeHolder(): string {
226 if (undefined !== this._placeHolder) {
227 return this._placeHolder;
228 }
229
230 return '';
231 }
232
233 set placeHolder(value: string) {
234 this._placeHolder = value;
235 }
236
237 get initialValue(): string | number {
238 if (undefined !== this._initialValue) {
239 return this._initialValue;
240 }
241
242 return '';
243 }
244
245 set initialValue(value: string | number) {
246 this._initialValue = value;
247 }
248
249 get selectOptions(): string[] {
250 if (undefined !== this._selectOptions) {
251 return this._selectOptions;
252 }
253
254 return [];
255 }
256
257 set selectOptions(value: string[]) {
258 this._selectOptions = value;
259 }
260
261 get hideLabel(): boolean {
262 if (undefined !== this._hideLabel) {
263 return this._hideLabel;
264 }
265
266 return false;
267 }
268
269 set hideLabel(value: boolean) {
270 this._hideLabel = value;
271 }
272}
1import { ChangeEvent, SyntheticEvent } from 'react';
2
3import styles from './form.module.css';
4import { FormInput, InputTypes } from './form-input';
5interface FormProperties {
6 cancelFunction?: () => any;
7 cancelText?: string;
8 clearFormAfterSubmit?: boolean;
9 disabled?: boolean;
10 hideButton?: boolean;
11 inputObjects: FormInput[];
12 inputsState: Record<string, string | number | undefined>;
13 onChangeFunc?: (event: ChangeEvent) => any;
14 pattern?: string;
15 postSubmitFunc?: (...arguments_: any) => any;
16 setInputsState: any;
17 submitText?: string;
18}
19
20export const Form = ({
21 hideButton = false,
22 inputObjects,
23 inputsState,
24 setInputsState,
25 postSubmitFunc,
26 clearFormAfterSubmit = true,
27 onChangeFunc,
28 submitText = 'Submit',
29 cancelText = 'Cancel',
30 cancelFunction,
31 disabled = false,
32 pattern,
33}: FormProperties): JSX.Element => {
34 const handleChange = (event: ChangeEvent): void => {
35 // @ts-expect-error Allow variable typing for this function
36 let { value, name, type, files } = event.target;
37
38 if (type === 'number') {
39 value = Number.parseInt(value, 10);
40 }
41
42 if (type === 'file') {
43 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
44 [value] = files;
45 }
46
47 // eslint-disable-next-line @typescript-eslint/no-unsafe-call
48 setInputsState({
49 ...inputsState,
50 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
51 [name]: value,
52 });
53
54 if (onChangeFunc) {
55 onChangeFunc(event);
56 }
57 };
58
59 const handleSubmit = async (event: {
60 preventDefault: () => void;
61 }): Promise<void> => {
62 event.preventDefault();
63
64 if (postSubmitFunc) {
65 const blankState = Object.fromEntries(
66 Object.entries(inputsState).map(([key]) => [key, ''])
67 );
68
69 if (clearFormAfterSubmit) {
70 // eslint-disable-next-line @typescript-eslint/no-unsafe-call
71 setInputsState(blankState);
72 }
73
74 postSubmitFunc();
75 }
76 };
77
78 const getInputElement = (formInput: FormInput): JSX.Element => {
79 switch (formInput.type) {
80 case InputTypes.textarea: {
81 return (
82 <textarea
83 required={formInput.required}
84 id={formInput.id}
85 name={formInput.name}
86 placeholder={formInput.placeHolder}
87 value={inputsState[formInput.name]}
88 cols={30}
89 rows={5}
90 onChange={handleChange}
91 />
92 );
93 }
94
95 case InputTypes.select: {
96 return (
97 // This rule is deprecated.
98 // eslint-disable-next-line jsx-a11y/no-onchange
99 <select
100 name={formInput.name}
101 id={formInput.name}
102 value={inputsState[formInput.name]}
103 onChange={handleChange}
104 >
105 {formInput.selectOptions.map(selectOption => {
106 return (
107 <option key={selectOption.valueOf()}>{selectOption}</option>
108 );
109 })}
110 </select>
111 );
112 }
113
114 default: {
115 return (
116 <input
117 required={formInput.required}
118 type={formInput.type}
119 id={formInput.id}
120 name={formInput.name}
121 placeholder={formInput.placeHolder}
122 autoComplete={formInput.autocomplete}
123 value={inputsState[formInput.name]}
124 pattern={pattern}
125 onClick={(event: SyntheticEvent): void => {
126 event.stopPropagation();
127 }}
128 onChange={handleChange}
129 />
130 );
131 }
132 }
133 };
134
135 return (
136 <form className={styles.Form} method="POST" onSubmit={handleSubmit}>
137 <fieldset disabled={disabled}>
138 {inputObjects.map((formInput: FormInput) => (
139 <div key={formInput.id}>
140 {formInput.hideLabel ? (
141 getInputElement(formInput)
142 ) : (
143 <label htmlFor={formInput.label}>
144 {formInput.displayName}:
145 <br />
146 {getInputElement(formInput)}
147 </label>
148 )}
149 <div className={styles.LabelLineBreak} />
150 </div>
151 ))}
152 {postSubmitFunc && !hideButton && (
153 <div className={styles.ButtonContainer}>
154 <button className={styles.SubmitButton} type="submit">
155 {submitText}
156 </button>
157 {cancelFunction && (
158 <button
159 className={styles.CancelButton}
160 type="button"
161 onClick={cancelFunction}
162 >
163 {cancelText}
164 </button>
165 )}
166 </div>
167 )}
168 </fieldset>
169 </form>
170 );
171};
Don't Abstract Fetch
I'm working with a lot of APIs. Twilio, Rocket Chat, FileCloud, Outlook, and of course our own. Using REST, WebSockets, and WebRTC. I'm doing server-side calls, client-side calls, polling, cache invalidation, and listening to websockets. Fetching has become my biggest pain point on this project.
For get requests I use useSWR and for posts I wrote a custom usePost hook (as useSWR isn't really intended for pushing data).
It's tempting to create a file that exports a bunch of fetch requests to any endpoint so you can use them anywhere in the application. But this doesn't sit right with me. Do you export promises? Or do you create hooks with state and useEffect? How do you call those hooks conditionally? How do you handle promises in non-async functions? What happens when you got a lot of requests for a complex page with many integrations?
useSwr is good at handling conditional calls. If the URL is null, it won't do anything. But if that changes, it will. A little something like the below will make sure the call is not made until userId is available. (For example, if you're pulling it from state.) You can expand this logic however you like.
1const {data} = useSwr(userId ? `domain.com/${userId}` : null)
My usePost hook works the same way, except instead of running as soon as url is available, it exports an execute function.
1import Error from 'next/error';
2import { useCallback, useEffect, useRef, useState } from 'react';
3import { animationInterval } from '../../util/time';
4
5interface UsePostProperties {
6 body?: BodyInit;
7 headers?: HeadersInit;
8 pollInterval?: number;
9 returnType?: 'json' | 'text';
10 url: string | null;
11}
12
13export interface UsePostReturn<Type> {
14 error?: Error;
15 execute: () => void;
16 pending: boolean;
17 value?: Type;
18}
19
20export const usePost = <Type>({
21 body,
22 headers = { 'Content-Type': 'application/json' },
23 url,
24 pollInterval,
25 returnType = 'json',
26}: UsePostProperties): UsePostReturn<Type> => {
27 const [pending, setPending] = useState(false);
28 const [value, setValue] = useState<Type>();
29 const [error, setError] = useState<Error>();
30 const executeReference = useRef<() => void>();
31
32 const execute = useCallback(async () => {
33 if (url === null) {
34 return;
35 }
36
37 setPending(true);
38
39 try {
40 const init = {
41 body,
42 headers,
43 method: 'POST',
44 };
45 const fetchResponse = await fetch(url, init);
46
47 if (returnType === 'json') {
48 setValue(await fetchResponse.json());
49 }
50 } catch (error_: unknown) {
51 if (error_ instanceof Error) {
52 setError(error_);
53 }
54 }
55
56 setPending(false);
57 }, [body, headers, returnType, url]);
58 // Execution function is assigned to a reference to avoid an infinite loop problem in useEffect.
59 // The ref will always reference the most recent version of execute rather than a version of execute that's created for every render.
60 // This means that when using this hook in an effect you can properly list execute as a dependency. (Make sure to do so.)
61 executeReference.current = execute;
62
63 useEffect(() => {
64 const controller = new AbortController();
65 if (pollInterval) {
66 animationInterval(pollInterval, controller.signal, execute);
67 }
68
69 return (): void => {
70 controller.abort();
71 };
72 }, [execute, pollInterval]);
73
74 return { error, execute: executeReference.current, pending, value };
75};
So what's the problem with this? Chaining requests is the problem. Let's say in order to create a group file share you have to login to the api, grab cookies off the response, use those to create a directory, add a “share” to it, take the shareId to update its name, and mark it as private, add each user to the directory one at a time, then set each user's permissions in that directory one at a time. That's a minimum of 6 REST API calls that have to be done in order. What do you do? Link a bunch of useEffects together?
You might say, that's a job for the backend. Great, with Next.js we have our own Node API. It's perfect for this kind of thing. (I'll get back to some other use cases for the Next API later). However, things didn't always work out like this for me. There were a lot of cases where I needed live data flow to change the client-side. It's just not always optimal to do everything server-side. (We'll get back to this too.) And I did, admittedly, do some pretty nasty useEffect chaining. (By this I mean one useEffect has the returned data of another useEffect as a dependency.)
Here's what I found that works better:
1import { RocketChatCreateUserResponse } from '../../types/RocketChat/rocket-chat-create-user-response';
2import { RocketChatRestGetRoomInfoResponse } from '../../types/RocketChat/rocket-chat-rest-get-room-info';
3import { RocketChatRestLoginResponse } from '../../types/RocketChat/rocket-chat-rest-login-response';
4import { RocketChatUsersInfoResponse } from '../../types/RocketChat/rocket-chat-users-info-response';
5import { jsonHeader } from '../../util/http';
6import { TEMP_GUEST_PASS } from '../FileCloud/file-cloud-controller';
7import { API_ROCKET_CHAT_BASE, API_ROCKET_CHAT_LOGIN } from './constants';
8
9export class RocketChatController {
10 public authToken?: string;
11 public isLoggedIn?: boolean;
12 public userId?: string;
13 private _loginHeader?: Record<string, string>;
14
15 public login = async (
16 username = process.env.ROCKET_CHAT_ADMIN_USERNAME,
17 password = process.env.ROCKET_CHAT_ADMIN_PASSWORD
18 ): Promise<void> => {
19 const rocketChatAdminLoginResponse = await fetch(
20 `${API_ROCKET_CHAT_LOGIN}`,
21 {
22 body: JSON.stringify({
23 password,
24 username,
25 }),
26 headers: jsonHeader,
27 method: 'POST',
28 }
29 );
30 const rocketChatAdminLoginData =
31 (await rocketChatAdminLoginResponse.json()) as RocketChatRestLoginResponse;
32 this.authToken = rocketChatAdminLoginData.data?.authToken;
33 this.userId = rocketChatAdminLoginData.data?.userId;
34 this.getLoginHeader();
35
36 this.isLoggedIn = Boolean(this.authToken && this.userId);
37 };
38
39 public addToChannel = async (
40 roomId: string,
41 userId: string
42 ): Promise<void> => {
43 await fetch(`${API_ROCKET_CHAT_BASE}channels.invite`, {
44 body: JSON.stringify({
45 roomId,
46 userId,
47 }),
48 headers: jsonHeader,
49 method: 'POST',
50 });
51 };
52
53 public createUser = async (
54 email: string,
55 name: string,
56 username: string,
57 password = TEMP_GUEST_PASS
58 ): Promise<RocketChatCreateUserResponse | RocketChatUsersInfoResponse> => {
59 if (this._loginHeader) {
60 const createRocketChatUserResponse = await fetch(
61 `${API_ROCKET_CHAT_BASE}users.create`,
62 {
63 body: JSON.stringify({
64 email,
65 name,
66 password,
67 username,
68 }),
69 headers: {
70 ...jsonHeader,
71 ...this._loginHeader,
72 },
73 method: 'POST',
74 }
75 );
76
77 const createRocketChatUserData =
78 (await createRocketChatUserResponse.json()) as RocketChatCreateUserResponse;
79
80 if (createRocketChatUserData.success) {
81 return createRocketChatUserData;
82 }
83
84 return this.getUserInfo(username);
85 }
86
87 throw new Error('Not logged in.');
88 };
89
90 public getRoomInfo = async (
91 roomName: string
92 ): Promise<RocketChatRestGetRoomInfoResponse> => {
93 if (this._loginHeader) {
94 const roomInfoResponse = await fetch(
95 `${API_ROCKET_CHAT_BASE}rooms.info?roomName=${roomName}`,
96 {
97 headers: this._loginHeader,
98 }
99 );
100 return (await roomInfoResponse.json()) as RocketChatRestGetRoomInfoResponse;
101 }
102
103 throw new Error('You must be logged in to do this.');
104 };
105
106 public getUserInfo = async (
107 userId?: string,
108 username?: string
109 ): Promise<RocketChatUsersInfoResponse> => {
110 let queryString = null;
111 if (userId) {
112 queryString = `userId=${userId}`;
113 } else if (username) {
114 queryString = `username=${username}`;
115 }
116
117 if (this._loginHeader && queryString) {
118 const getUserInfoResponse = await fetch(
119 `${API_ROCKET_CHAT_BASE}users.info?${queryString}`,
120 { headers: this._loginHeader }
121 );
122 return (await getUserInfoResponse.json()) as RocketChatUsersInfoResponse;
123 }
124
125 throw new Error(
126 'Must be logged in and must pass either userId or username.'
127 );
128 };
129
130 private readonly getLoginHeader = (): Record<string, string> | null => {
131 if (this.authToken && this.userId) {
132 this._loginHeader = {
133 'X-Auth-Token': this.authToken,
134 'X-User-Id': this.userId,
135 };
136 }
137
138 return null;
139 };
140}
TypeScript gives us the opportunity to build some good ol' OOP classes. An object with methods, and an instantiation that can keep track of any properties. ...It just feels structured. Now I know, hooks are brilliant. I love stumbling across new hook repos just to see what people have done with them. But they have rules. Rules you should follow. And if those rules conflict with what you're trying to do. Find another way.
A TypeScript class is code that can be shared by both the Next API and your client. So wherever it is most appropriate to make these calls, you can do it with the same code. This is pretty freaking cool if you ask me.
1const rocketChat = new RocketChatController();
2await rocketChat.login();
Don't SSR Everything
Just because you're using Next, doesn't mean everything should be SSR.
Next does a lot of cool optimization without any input from you. CSS for example is rendered server-side. (I also highly recommend using CSS modules that React supports out of the box.) SSR isn't the only reason to use Next and it's not the only tool it has. Use the tools you need, when you need them.
Get familiar with all of the tools Next provides for data fetching. Familiarize yourself with useSWR (previously mentioned) and don't be afraid to do a good ol' client-side fetch. Especially when dealing with live or dynamic data. You should know these tools and when to use them. Putting too much work on the server is just as bad as putting too much work on the browser. Split it up, load only what you need, when you need it.
Learn From Smart People, Don't Stop Learning
I initially learned React from Wes Bos. and did a lot ofAndrei Neagoie and Stephen Grider courses. Since then I've come across guys like Dan Abramov and Harry Wolf. I've even linked some of their articles in my documentation of this project. Specifically:
- A Complete Guide to useEffect (Please read this!)
- Why I love useReducer
- Level Up useReducer with Immer
- How to useContext with useReducer
Don't ever think you know everything, you never will. I've come across know-it-alls, worked with know-it-alls, and seen projects decimated by know-it-alls. Stay open, keep finding new perspectives, keep getting deeper, and never let Einstellung set in.
Use The Next API
Even if you're not intending to use Next for your entire stack, the Next API is a quick and easy way to solve some problems. Fixing bad REST APIs is one of them. Got an endpoint that returns too much data and doesn't have an option to paginate? Send it through Next, paginate the results and give yourself a structure that works well with useSWR.
Need to make... ahem.... 6 REST calls to do one thing? Bundle it into a single call you can make on your front end.
Want to work with environment variables that you don't want the client to have access to? API routes can access environment variables that are not NEXT_PUBLIC.
Keep Scaling, Keep Refactoring
Don't get stuck with mistakes. I moved this project from taking two weeks to add a feature, to a few hours. (So long as an API doesn't give me too much trouble.) I've got a lot of places in my code that I need to clean up. Fetch requests being one of them. I only thought of using classes recently.
We're working on a GraphQL server that we're adding both our database and our third-party APIs to. We're discovering that we can create whatever crazy relationship we want to. A user's email on our database can fit nicely into a user search for RocketChat. Add it to the graph. Those chaining rest calls could be a series of relationships. Add it to the graph. Because our users have the same credentials for both the app and the integrations, we can start linking things together. Add it to the graph. For example:
1{
2 login(username, password) {
3 user {
4 userId
5 rocketChat {
6 avatar
7 }
8 outlook {
9 unreadMessages
10 }
11 }
12 }
13}
Why not? Having a separate server that allows us to make these kinds of calls to basically abstract out interacting with data sources will be a huge boost.
I've thought about hooking something like Prisma up to connect Next directly to our database. But with a GraphQL server, I don't think that will be necessary or even preferable. But if I have a use for it, I'd like to keep that in my back pocket. I mean, maybe there's a use for an SQLite server working with Prisma? A cool little in-memory app DB. Who knows, I'm keeping an open mind.
If you don't keep improving your code, it's going to fall apart. If you've got a team that's afraid to step on each other's toes, or is paralyzed by a buggy codebase. Pause, reevaluate, and understand that time is an investment. If you want fast development and high productivity you need to invest in the foundation of your work. Don't think you can get there by brute force. And don't think you're better than “new-fangled” technologies. That kind of attitude will keep you and your team back.